简介基于多列卷积MCNN的密集人群计数项目源码包面向机器学习、人工智能及相关专业学生适合课程设计、毕业设计或项目初期演示等场景可帮助快速复现人群密度估计与计数模型也适合作为进阶学习或二次开发的基础。资源以源码、文档、数据集、模型权重四位一体共2000个文件1985个npy数据文件、9个Python脚本、5个xml配置文件和1个md说明文档压缩包约123MB。脚本分工清晰dataloader.py实现数据预处理train.py负责训练与测试models目录提供两个结构略有差异的MCNN网络数据涵盖ShanghaiTech和UCSD两套公开数据集checkpoints目录存放训练好的权重draft目录保留开发草稿便于对照理解网络设计与调参过程。代码经作者测试通过答辩评审平均分达96分下载后按README说明修改路径即可运行不熟悉环境配置时还可私信作者获得远程教学支持。目前已有96人学习下载适合需要完整项目参考的学生直接使用或在基础上扩展。1. 密集人群计数为什么值得用多列卷积把一张人头攒动的景区照片丢给计算机问它里面到底有多少人——这就是密集人群计数的任务。早年大家用检测器数人头密集场景下遮挡严重漏检率极高后来转向回归密度图让网络学会预测「每个像素附近有多少人」。但很快大家发现单列网络在这个任务上会集体翻车前排的人占了几百个像素后排的人只有十几个像素同一张图里尺度差异大到网络顾此失彼。多列卷积这个思路正是冲着这种尺度差异来的——用不同感受野的卷积核并行提取特征再把多路信息融合起来相当于给模型配了一组不同焦距的镜头。这篇笔记把它从原理拆到代码再给出一套能直接跑通的数据准备、训练、评估和排错路径适合做课程设计或小规模项目的同学参考。2. 多列卷积的结构原理与密度图生成动手前先把两件事想清楚2.1 多列卷积到底在解决什么问题人群计数的难点可以总结成四个字尺度变化。同一个场景里离镜头近的人头部区域可能占据 (40\times40) 像素而远处的人群头部区域可能只有 (5\times5) 像素。单列卷积网络无论怎么调卷积核尺寸都很难同时兼顾这两类目标。多列卷积的思路是并行使用多个不同感受野的卷积分支每条分支负责一个尺度范围最后把特征拼接起来。常见的多列结构是三列分别使用 (3\times3)、(5\times5)、(7\times7) 的卷积核。(3\times3) 分支擅长捕捉小尺度头部目标(7\times7) 分支感受野更大能覆盖中大型头部以及拥挤群体间的上下文关系。这种设计不需要引入额外的注意力机制或复杂模块纯粹靠多分支结构扩大网络的表达能力训练和数据准备相对简单因此特别适合课程设计这种时间有限、算力有限的场景。需要说明的是多列卷积并不一定意味着参数量一定很大。三个分支在浅层并行深层融合后再继续卷积整体参数量大约只是单列网络的 2~3 倍。如果你的机器跑不动三个分支也可以先做主分支 辅助分支的两列结构效果往往也能明显超过单列基线。2.2 从点标注到密度图最关键的一步数据预处理人群计数数据集里通常只给出每个头部的中心点坐标而不是框。模型并不直接预测坐标而是预测一张密度图所以第一步必须把点标注转换成密度图。密度图的核心思路是在每个头部中心点位置放置一个高斯核再把所有高斯核叠加起来。密度图在某个像素上的值表示该位置附近有多少个人。对整张密度图求和就能得到总人数。这种做法背后的假设是每个人头在图像上是一个小区域其中心附近概率密度最高向外递减。我在实际项目里见过不少同学直接用一个固定方差的高斯核效果时好时坏。密集人群中头与头之间的距离差异极大固定方差在小头密集场景下会把多个头的高斯核糊成一片。比较稳妥的做法是「几何自适应高斯核」对每个人头计算它到最近的几个相邻人头中心的距离取平均值作为该处高斯核的标准差。这样人头密集的地方核更尖锐稀疏的地方核更平缓。下面这段代码展示了如何把点标注转换成密度图这是整个项目中我建议你第一个自己动手实现、并且反复检查的函数import numpy as np from scipy.ndimage import gaussian_filter def points_to_density_map(height, width, points): 把头部中心点标注转换为密度图。 points: shape (N, 2)每行是 (x, y) 归一化坐标或像素坐标这里约定为像素坐标。 返回: shape (height, width) 的 float32 密度图 density np.zeros((height, width), dtypenp.float32) for i in range(len(points)): x, y int(points[i][0]), int(points[i][1]) if x 0 or x width or y 0 or y height: continue # 越界点直接丢弃否则会污染边界区域 # 计算该点到最近 k 个邻居的平均距离作为自适应 sigma dists [] for j in range(len(points)): if i j: continue dx points[j][0] - points[i][0] dy points[j][1] - points[i][1] dists.append(np.sqrt(dx * dx dy * dy)) if len(dists) 0: sigma 15.0 # 场景里只有一个人的兜底值 else: dists.sort() k min(3, len(dists)) # 取最近 3 个邻居通常够用 sigma float(np.mean(dists[:k])) sigma max(3.0, min(sigma, 20.0)) # 限制范围防止核过于尖锐或过于发散 # 用 scipy 在密度图上叠加一个高斯核 # 这里用 gaussian_filter 对整个图操作一次会更快但逐点叠加逻辑更清晰 x_idx np.arange(width) y_idx np.arange(height) gx np.exp(-((x_idx - x) ** 2) / (2 * sigma * sigma)) gy np.exp(-((y_idx - y) ** 2) / (2 * sigma * sigma)) density np.outer(gy, gx) # 归一化可选让全图积分等于总人数 total density.sum() if total 0: density density * (len(points) / total) return density这段代码有两点值得注意。第一自适应 sigma 用了最近 3 个邻居的平均距离这是实践中的常见折中邻居太少则方差波动大邻居太多则近景区域核被远处稀疏人群拉大导致定位变糊。课程设计场景下这个 k 值不用调得太细但你要理解它存在的意义。第二最后做了一次整体归一化保证密度图积分严格等于标注人数。这个归一化能有效防止网络在训练时学到「少预测一点」的偷懒解。实际处理大批量数据时逐点循环生成高斯核比较慢。更快的做法是先用 np.histogram2d 把点投影到一张计数图上再用 scipy.ndimage.gaussian_filter 做一次卷积效果等同于为每个点放置固定 sigma 的高斯核。但如果你想做自适应 sigma就只能走逐点路线建议提前离线生成所有密度图存成 npy 文件而不是在 Dataset 里每次读取时都现算否则训练速度会很难看。2.3 数据集切分与加载别把所有图都扔进训练集人群计数模型很容易过拟合因为数据集的图像往往来自少数几个固定场景场景背景、拍摄角度和人群分布存在明显的重复模式。我见过有人把同一场景的连续帧既放进训练集又放进测试集最终指标虚高答辩时被老师一问就露馅。正确做法是在图片层面按场景切分保证同一场景的不同帧只出现在一个集合里。如果使用公开数据集场景划分通常已经在文档中给出但如果你自己收集数据就要格外小心这一点。按 7/2/1 的比例划分训练集、验证集、测试集是常见做法验证集用于监控训练过程中的过拟合测试集只用于最终评估平时不碰。训练时还有一个容易被忽略的问题图像尺寸。人群计数数据集里的图像分辨率通常较高比如 (1024\times768)直接整图送入网络显存很容易被吃满。课程设计阶段最常见做法是按比例缩放到长边约 512 或 768 像素再做随机裁剪。下面给出一个简化版的 Dataset 加载代码import torch from torch.utils.data import Dataset from PIL import Image import numpy as np import os class CrowdDataset(Dataset): def __init__(self, img_dir, gt_dir, size(512, 512), trainTrue): self.img_dir img_dir # 图像文件夹 self.gt_dir gt_dir # 密度图 .npy 文件夹 self.size size self.train train self.img_names sorted([f for f in os.listdir(img_dir) if f.endswith(.jpg)]) def __len__(self): return len(self.img_names) def __getitem__(self, idx): img_name self.img_names[idx] img Image.open(os.path.join(self.img_dir, img_name)).convert(RGB) gt np.load(os.path.join(self.gt_dir, img_name.replace(.jpg, .npy))) # 统一缩放到固定尺寸注意密度图也要同步缩放 img img.resize(self.size, Image.BILINEAR) scale_x self.size[0] / img.width scale_y self.size[1] / img.height gt_img Image.fromarray(gt.astype(np.float32)) gt np.array(gt_img.resize(self.size, Image.BILINEAR)).astype(np.float32) # 因为密度图是计数语义缩放后要乘面积比保持人数不变 gt gt * (scale_x * scale_y) img np.array(img).astype(np.float32) / 255.0 img torch.from_numpy(img).permute(2, 0, 1) gt torch.from_numpy(gt).unsqueeze(0) return img, gt这里最容易被忽略也最关键的是最后一行乘法图像缩放后密度图必须乘上缩放系数的乘积否则密度图积分代表的人数会变少。很多人的模型在验证集上人数预测系统性偏低排查半天发现是这里出了问题。DataLoader 里的 batch_size 和 num_workers 也要注意。课程设计的机器通常内存不大num_workers 设成 4 到 8 就够再高反而容易因为磁盘 IO 瓶颈出现卡顿。pin_memory 设为 True 能小幅提升 GPU 训练时的数据拷贝速度顺手加上没有坏处。3. 搭建多列卷积模型并训练从网络定义到 loss 曲线怎么看3.1 三列卷积网络用 PyTorch 实现一个能跑的基线模型多列卷积网络的结构可以抽象成三个部分共享输入、多分支特征提取、融合回归。下面给出的实现是课程设计里最常用的三列结构每列卷积核尺寸不同融合后接几层卷积回归密度图。import torch import torch.nn as nn class MultiColumnCrowdNet(nn.Module): 三列卷积人群计数网络。 分支1: 3x3 卷积负责小尺度头部 分支2: 5x5 卷积负责中等尺度 分支3: 7x7 卷积负责大尺度/上下文 最后 concat 融合回归密度图。 def __init__(self): super().__init__() def branch(in_ch, h_dim, kernel): pad kernel // 2 return nn.Sequential( nn.Conv2d(in_ch, h_dim, kernel, paddingpad), nn.ReLU(inplaceTrue), nn.Conv2d(h_dim, h_dim, kernel, paddingpad), nn.ReLU(inplaceTrue), nn.Conv2d(h_dim, h_dim, kernel, paddingpad), nn.ReLU(inplaceTrue), ) self.branch1 branch(3, 32, 3) # 小感受野捕捉小头目标 self.branch2 branch(3, 32, 5) # 中感受野 self.branch3 branch(3, 32, 7) # 大感受野 # 三个分支输出 concat 后通道数为 96 self.fuse nn.Sequential( nn.Conv2d(96, 64, 1, padding0), nn.ReLU(inplaceTrue), nn.Conv2d(64, 32, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(32, 1, 1, padding0), ) def forward(self, x): f1 self.branch1(x) f2 self.branch2(x) f3 self.branch3(x) feat torch.cat([f1, f2, f3], dim1) return self.fuse(feat)这个网络有几个设计点值得展开说。第一每个分支内部都用三层同尺寸卷积而不是直接堆不同尺寸原因是单层大卷积核的参数量大且容易过拟合叠三个小层既保证了深度又让各分支能独立学习到对应尺度的纹理特征。第二融合层第一层用 (1\times1) 卷积作用是把三列特征在通道维度上做压缩混合而不是简单相加——相加会损失各分支的独立信息。第三最后一层卷积输出单通道密度图中间没有加全局池化或全连接因为人群计数要求输出和输入分辨率对齐不能用分类网络那种全局特征。相比空洞卷积类的单列网络多列卷积在这种小规模课程设计任务上有两个实际优势训练更稳定不易梯度消失对输入分辨率变化的鲁棒性更强。缺点是参数量大一些后面我们会讨论怎么在显存有限时做裁剪训练。3.2 损失函数与优化器MSE 为什么是默认选择人群计数最常用的损失函数是均方误差MSE也就是密度图每个像素上预测值与真实值的差的平方的均值。它直接衡量密度图的逐像素误差对位置偏差比较敏感也能很好地反映总人数偏差。L1 损失MAE也可以但训练收敛后精度略差且对密度图中大量零像素区域的惩罚不够平滑。优化器我一般选 Adam学习率从 (1e-4) 起步。课程设计的数据量通常不大用 SGD 需要精心调学习率和动量Adam 对大多数情况「一把梭」也能收敛。weight decay 设成 (5e-4)可以缓解过拟合。训练主循环的伪代码很直接但有几个细节值得写清楚。每个 epoch 结束要在验证集上算一次 MAE而不是只看训练 loss。训练 loss 持续下降但验证集 MAE 开始上升就是过拟合的信号此时可以提前停止。model MultiColumnCrowdNet().cuda() optimizer torch.optim.Adam(model.parameters(), lr1e-4, weight_decay5e-4) criterion nn.MSELoss() train_loader DataLoader(train_set, batch_size4, shuffleTrue, num_workers4) val_loader DataLoader(val_set, batch_size1, shuffleFalse, num_workers2) for epoch in range(30): model.train() running_loss 0.0 for imgs, gts in train_loader: imgs, gts imgs.cuda(), gts.cuda() preds model(imgs) loss criterion(preds, gts) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() * imgs.size(0) # 验证计算 MAE平均绝对误差单位人 model.eval() mae_total 0.0 with torch.no_grad(): for imgs, gts in val_loader: imgs, gts imgs.cuda(), gts.cuda() preds model(imgs) pred_counts preds.sum(dim(1, 2, 3)) gt_counts gts.sum(dim(1, 2, 3)) mae_total torch.abs(pred_counts - gt_counts).sum().item() print(fEpoch {epoch}: loss{running_loss / len(train_set):.4f}, val_MAE{mae_total / len(val_set):.2f})batch_size 选 4 是基于 512×512 输入尺寸、显存在 8G 左右的假设。如果你的显卡只有 4G可以把 batch_size 降到 2同时把输入尺寸降到 384×384。注意 batch_size 缩小后损失函数的梯度噪声会变大学习率也可以同比例下调到 (5e-5) 附近。训练过程中有几条经验值得记下来。第一条如果 loss 在前 5 个 epoch 内几乎不动先检查密度图是否真的出现了明显的高斯分布很多人倒在数据预处理这一步。第二条如果 loss 下降很快但验证集 MAE 波动很大几乎可以断定是数据划分有问题——测试集里混进了跟训练集相同场景的不同帧模型在背答案。第三条人群计数训练初始阶段预测人数会普遍偏小因为网络需要先学会「从零开始激活密度图」这一点不是 bug耐心多跑几个 epoch。3.3 显存不够怎么办随机裁剪是课程设计的救命稻草整图训练显存不够是几乎所有课程设计都会撞上的事情。与其把整张图压到很小的分辨率导致人头糊成一团不如从原图和高斯密度图中随机裁剪若干 256×256 的 patch 来训练。这样做有两个好处显存占用大幅降低同时每个 patch 都是独立样本相当于做了数据增强。裁剪时有两个坑。第一个坑是随机裁剪很可能裁到完全没有人的空白区域这些 patch 中的密度图全为 0。这样的样本占比太高会让模型倾向于输出全零密度图。常见做法是设定一个阈值——如果裁剪 patch 中的人数少于某个最小值例如 5 人就重新裁或者以中心点引导采样优先裁人群密集的区域。第二个坑是 patch 之间的人头可能被截断导致边界处的头部只露出一部分。这个问题可以通过以人头中心为圆心随机扰动裁切窗口来缓解让部分被裁掉的人头仍然以完整高斯核进入 patch。下面是一段实用的 patch 采样逻辑放在 Dataset 的__getitem__之前比较合适def random_crop_pair(img_array, gt_array, crop_size256, min_count5): 从原图和密度图中同步裁剪 patch保证密度图人数不少于 min_count。 h, w img_array.shape[:2] for _ in range(20): # 最多尝试 20 次 x np.random.randint(0, w - crop_size) if w crop_size else 0 y np.random.randint(0, h - crop_size) if h crop_size else 0 img_patch img_array[y:ycrop_size, x:xcrop_size] gt_patch gt_array[y:ycrop_size, x:xcrop_size] if gt_patch.sum() min_count: return img_patch, gt_patch # 兜底强制返回左上角 patch return img_array[:crop_size, :crop_size], gt_array[:crop_size, :crop_size]训练时用裁剪的 patch验证时仍然用完整图像做预测两者不冲突。最终测试时模型看到的图像尺寸与训练时不一致也没关系因为全卷积网络对输入尺寸没有限制。这样做的好处是可以随时换一张任意分辨率的图推理而不用重新训练。4. 评估与推理MAE、MSE 和预测可视化到底怎么看4.1 MAE 和 MSE两个指标分别告诉你什么人群计数的标准评估指标是两个平均绝对误差MAE和均方误差MSE。MAE 衡量预测人数与真实人数之间的平均绝对差距单位是「人」直觉上最容易理解。MSE 是误差平方的平均再开根号它对大误差的惩罚更重。举个例子三张测试图真实人数分别是 100、200、300预测分别是 105、190、310那么 MAE (51010)/3 ≈ 8.33 人MSE sqrt((25100100)/3) ≈ 8.66 人。如果某一张图预测严重偏差比如把 200 人预测成 150MAE 只增加 50/3而 MSE 会因为平方项放大这个错误。课程设计报告中建议两个指标都列出MAE 作为主要指标MSE 说明模型的稳定性。老师在答辩时通常更关注 MAE但也可能会问你对 MSE 的理解能答清楚「MSE 对大误差更敏感反映模型在极端拥挤场景下的稳定性」这一点会加分不少。一般来说模型在测试集上的 MAE 如果显著小于训练集说明测试集场景太简单或者与训练集有重叠如果显著大于训练集则是过拟合或场景差异太大。4.2 推理脚本从一张图到预测人数推理流程和训练的前向过程一致但有几个细节需要处理好。加载权重后要把模型设为 eval 模式关闭 dropout 和 batch norm 的更新。输入图像要经过与训练时相同的预处理——缩放、归一化到 [0,1]。预测出的密度图每个值都是非负的浮点数直接求和就是总人数。def predict_count(model, image_path, device, target_width1024): 对单张图片推理返回预测总人数和密度图。 img Image.open(image_path).convert(RGB) w, h img.size # 按长边缩放保持宽高比 if w h: new_w target_width new_h int(h * target_width / w) else: new_h target_width new_w int(w * target_width / h) img img.resize((new_w, new_h), Image.BILINEAR) img_tensor torch.from_numpy( np.array(img).astype(np.float32) / 255.0 ).permute(2, 0, 1).unsqueeze(0).to(device) model.eval() with torch.no_grad(): density model(img_tensor) # shape (1, 1, H, W) count density.sum().item() return count, density[0, 0].cpu().numpy()这段代码里最关键的一步是放缩图像时保持了宽高比而不是直接强制 resize 到正方形。原因很简单强行拉伸会把人的形状变宽或变窄改变头部的尺度分布而模型的分支设计和训练数据都围绕特定尺度分布来学习输入失真是导致推理误差的常见来源。推理时只做一次前向没有涉及梯度计算所以千万记得包在torch.no_grad()里。不包的话PyTorch 会为中间变量保存计算图显存占用瞬间飙高对推理场景完全没必要。还有一个常见问题预测的密度图里有极小概率出现负数。这是因为最后一层卷积没有经过激活函数网络在极端输入下可能输出负值。这些负值会拉低总人数。处理办法是推理时对密度图做 ReLU 截断——np.clip(density, 0, None)。但注意训练时不能用截断否则梯度会消失。4.3 可视化核对热力图叠加是判断模型质量的直观方式数值指标只能告诉你「偏了多少」但没法告诉你「为什么偏」。我习惯把每张测试图的预测密度图转成热力图跟原图左右并排或半透明叠加直接观察模型把人数「放」在了哪里。可视化通常能暴露出三类问题。第一类是密度图落在背景区域而非人头区域说明网络学会了「哪里有纹理就预测哪里」而不是真正学习了头部特征。第二类是近景的人头密度被低估、远景的人头被高估说明三列分支中不同尺度的特征融合权重不均衡。第三类是密度图呈条纹状或格子状往往是因为训练时用的高斯核 sigma 固定且过小密度图本身就呈网格状。写一个简单的 matplotlib 可视化脚本把原图、真实密度图、预测密度图三张图并排输出每次调参后都跑一遍比只看 loss 曲线和 MAE 数字要可靠得多。5. 避坑指南密集人群计数训练中的 5 个典型踩坑记录5.1 训练 loss 降了验证集 MAE 却纹丝不动现象训练集 loss 从 0.01 稳定降到 0.002但验证集 MAE 始终在 40~60 之间波动甚至略微上升。原因最常见的是数据划分没做场景隔离。验证集里有和训练集同场景的视频帧或相邻帧模型「记住了」该场景的背景布局和人群空间分布遇到新场景就原形毕露。此外验证时采用的图像缩放比例与训练时不统一导致密度图尺度错位同样会让指标失真。解决重新按场景切分数据保证同一场景的帧只属于一个集合。验证时固定使用 1024 像素长边缩放和训练时预处理严格一致。检查密度图缩放时有没有乘以面积因子这个因子漏掉会让人数系统性偏低。5.2 预测总人数系统性偏低而且低得很均匀现象多张测试图上预测人数都大约是真实人数的 60%~70%比例稳定。原因几乎可以锁定为密度图缩放问题。训练时图像从 1024×768 resize 到 512×512但密度图没有乘以对应的缩放面积因子网络学到的是「缩小后的密度图」对应的计数推理时自然系统性低估。另一个原因是密度图生成时归一化环节做错——用总人数除以了密度图总和但密度图总和本身因为高斯核太小而偏小导致真实密度被低估。解决回到points_to_density_map函数检查叠加高斯核后总和是否等于标注总人数。不等的话把归一化系数重新算一遍。这个 bug 的坑人之处在于 loss 依然能够收敛因为网络始终在拟合一个自洽的错误目标。5.3 显存不够batch_size 调到 1 还是 OOM现象batch_size 已经降到 1输入尺寸也压到 384×384依然显存溢出或者训练速度极慢。原因三个并行分支的中间特征图都会保存在显存中用于反向传播512×512 输入下三个分支的激活量加起来非常可观。另外很多人忘了把验证时创建的中间变量从计算图中分离。解决改用随机裁剪 256×256 patch 训练显存占用会断崖式下降。把 batch_size 设成 8 都不成问题。验证时用torch.no_grad()并主动把不用的变量置空。还有一个容易忽视的优化——数据加载 worker 数量过多也会占用额外显存如果 num_workers 设成了 8 以上试着降回 4。5.4 密度图在极密集区域「一坨糊」无法区分单个人头现象用固定 sigma 生成密度图时密集人群区域的高斯核相互重叠严重密度图看起来像一块均匀的亮斑训练后模型在这个区域的分辨能力几乎为零。原因固定 sigma 适合稀疏场景密集场景中头与头之间的实际距离远小于固定 sigma高斯核把相邻头部信息糊在了一起。模型的预测结果自然也只有「这块大概有多少人」的模糊估计没有精确到个位数的能力。解决改用几何自适应高斯核每个头部用其到最近 3 个邻居的平均距离作为 sigma。不过要注意自适应 sigma 在高密度区域会变得很小此时高斯核叠加时如果密度图分辨率不够会丢失部分能量。所以建议密度图与输入图像同分辨率不要下采样。评估时可以专门把测试图按人群密度分组统计 MAE你会发现在极密集区域 MAE 通常明显更大这是任务本身的固有难点。5.5 训练代码没问题但不同显卡上结果差异很大现象同一份代码、同样的超参数在 A 同学的显卡上训练 MAE 是 15换了一台机器变成 25大家都怀疑是随机种子没固定。原因PyTorch 的 CUDA 卷积在不同显卡架构上存在数值精度差异float32 的累加顺序不同结果会有微小偏差。但 MAE 出现巨大差异通常另有原因——两台机器上 CUDA 版本不同导致 cuDNN 选择了不同算法或者数据加载时图像插值方式不一致。解决训练开始前固定随机种子适当调低torch.backends.cudnn.benchmark False来避免算法选择造成的微小抖动。如果换了机器训练结果仍然差异明显先检查数据扩增、加载顺序和归一化方式是否完全一致。最强力的手段是保存并使用训练时的预处理函数不依赖对方机器的代码版本。6. 进阶验证技巧用分块对比定位模型在哪类场景失效训练结束后除了整套数据集的 MAE 之外我强烈建议你再做一个工作把每张测试图横向切成左、中、右三块或者按图像上下半部分划分分别统计每块的预测人数与真实人数的差异。这一步能把「平均误差 20 人」这个大数拆解成「远景区域偏了 3 人、近景密集区域偏了 60 人」这样可操作的信息让你知道下一步该往哪个方向调。def patch_wise_analysis(model, image_path, density_map, patch_size256): 把预测密度图和真实密度图按 patch 分块统计每个 patch 的人数误差。 density_map: 模型预测的密度图已还原到原始图像尺寸 true_density: 真实密度图与图像同尺寸 h, w true_density.shape errs [] for y in range(0, h, patch_size): for x in range(0, w, patch_size): pred_count density_map[y:ypatch_size, x:xpatch_size].sum() gt_count true_density[y:ypatch_size, x:xpatch_size].sum() errs.append((x, y, gt_count, pred_count, pred_count - gt_count)) return errs用这个脚本分析后你会发现问题几乎永远是两类第一类是在人或头特别密集的区域预测偏小——因为多列卷积的融合层把大尺度分支的特征权重压得太低第二类是在带有类似人形纹理的背景区域预测偏大——比如树荫下的斑驳光影、密集的护栏花纹。知道了具体在哪类区域出错调参才有方向。如果密集区域误差偏大增强大感受野分支的权重是最直接的调法把 7×7 分支的卷积核从 32 个增加到 48 个或者让融合层从concat改为加权求和。如果背景区域误报严重则要回到数据预处理把训练时的随机裁剪改为偏向含人群区域的采样让模型更多看到「人」而不是「背景样式」。我在自己的课程设计项目上走过不少弯路。最深刻的一条是当你不确定是网络结构问题、数据问题还是训练问题的时候先可视化预测密度图再用分块误差定位不要盲目调参。这套流程看下来可能觉得慢但它的收敛速度实际上比玄学调参快得多。希望这篇笔记能帮你少踩几个坑把时间花在真正有价值的地方。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?