简介:基于多列卷积(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), dtype=np.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), train=True): 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, padding=pad), nn.ReLU(inplace=True), nn.Conv2d(h_dim, h_dim, kernel, padding=pad), nn.ReLU(inplace=True), nn.Conv2d(h_dim, h_dim, kernel, padding=pad), nn.ReLU(inplace=True), ) 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, padding=0), nn.ReLU(inplace=True), nn.Conv2d(64, 32, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(32, 1, 1, padding=0), ) def forward(self, x): f1 = self.branch1(x) f2 = self.branch2(x) f3 = self.branch3(x) feat = torch.cat([f1, f2, f3], dim=1) 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(), lr=1e-4, weight_decay=5e-4) criterion = nn.MSELoss() train_loader = DataLoader(train_set, batch_size=4, shuffle=True, num_workers=4) val_loader = DataLoader(val_set, batch_size=1, shuffle=False, num_workers=2) 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(f"Epoch {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_size=256, min_count=5): """ 从原图和密度图中同步裁剪 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:y+crop_size, x:x+crop_size] gt_patch = gt_array[y:y+crop_size, x:x+crop_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 = (5+10+10)/3 ≈ 8.33 人;MSE = sqrt((25+100+100)/3) ≈ 8.66 人。如果某一张图预测严重偏差,比如把 200 人预测成 150,MAE 只增加 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_width=1024): """ 对单张图片推理,返回预测总人数和密度图。 """ 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_size=256): """ 把预测密度图和真实密度图按 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:y+patch_size, x:x+patch_size].sum() gt_count = true_density[y:y+patch_size, x:x+patch_size].sum() errs.append((x, y, gt_count, pred_count, pred_count - gt_count)) return errs用这个脚本分析后,你会发现问题几乎永远是两类:第一类是在人或头特别密集的区域预测偏小——因为多列卷积的融合层把大尺度分支的特征权重压得太低;第二类是在带有类似人形纹理的背景区域预测偏大——比如树荫下的斑驳光影、密集的护栏花纹。知道了具体在哪类区域出错,调参才有方向。
如果密集区域误差偏大,增强大感受野分支的权重是最直接的调法:把 7×7 分支的卷积核从 32 个增加到 48 个,或者让融合层从concat改为加权求和。如果背景区域误报严重,则要回到数据预处理,把训练时的随机裁剪改为偏向含人群区域的采样,让模型更多看到「人」而不是「背景样式」。
我在自己的课程设计项目上走过不少弯路。最深刻的一条是:当你不确定是网络结构问题、数据问题还是训练问题的时候,先可视化预测密度图,再用分块误差定位,不要盲目调参。这套流程看下来可能觉得慢,但它的收敛速度实际上比玄学调参快得多。希望这篇笔记能帮你少踩几个坑,把时间花在真正有价值的地方。
本文还有配套的精品资源,点击获取