写这篇博文之前我翻了翻GitHub的提交记录,又把自己跑实验时的终端日志翻出来核对了一遍。crowdcountingp2p这个项目,准确的论文名是《Crowd Counting via Perspective-Guided Point-to-Point Regression》,CVPR 2024的一篇工作,代码仓库在GitHub上可以公开访问。如果你关注人群计数、点监督回归或者视觉大模型以外的中大型视觉任务,最近搜“多模态模型代码复现”“fixmatch代码复现”这类词的时候,大概率也会被算法推到它。这个项目属于“入门容易、跑通中等、调到论文指标偏难”的类型,适合有一定PyTorch基础、想通过完整复现一篇顶会论文来熟悉“从数据到训练再到评估”全流程的人。我前后花了两周时间,中间踩了不少坑,这篇就把完整流程和那些文档里不会写的问题一起整理出来。
1. 复现前先搞懂P2P在解决什么问题
1.1 人群计数任务到底是什么
人群计数(Crowd Counting)是计算机视觉里一个很经典的任务:给定一张密集人群图像,预测图像中的人数。听起来简单,但难点在于密集场景下的尺度变化、遮挡和背景干扰。早期的做法是检测每个人头,但人群太密的时候检测框重叠严重,基本失效。后来主流方法转向密度图回归,也就是让模型预测一张和输入同尺寸的热力图,每个像素的值表示该位置的人头密度,对全图求和就是总人数。
密度图方法的代表是CSRNet、CAN等,它们的优势是能处理高密度场景,缺点是训练时需要生成密度图标注,而密度图由高斯核卷积得到,核大小和标准差是超参数,直接影响训练效果。这就引出了P2P论文试图解决的问题:能不能不依赖密度图,直接用点坐标来做回归。
P2P全称里的“Point-to-Point”指的就是这个思想——不是让模型去回归一整张密度图,而是直接预测每个人头点的坐标。这样做的好处很直接:标注本身就是点,不需要额外生成密度图,训练流程更简洁,部署时也不用再做密度图的积分求和。但难点也随之而来:怎么让网络直接输出可变数量的点坐标?怎么处理密集区域点的重叠?怎么让预测的点不会出现重复或者漏检?
1.2 P2P的核心思想:点对点回归与视角引导
P2P的完整方法名是“Perspective-Guided Point-to-Point Regression”,核心有两个关键词:Perspective-Guided(视角引导)和Point-to-Point(点对点)。
点对点回归部分,它采用了类似DETR的集合预测思路,但是针对人群场景做了大量适配。模型不是直接输出一堆坐标,而是先通过一个编码器提取特征,再用一个解码器预测一组“点提议”(point proposals),每个提议包含一个置信度分数和一个坐标偏移量。训练时用匈牙利匹配算法把预测点和真实点做一对一匹配,匹配成功的点算回归损失,未匹配的点算分类损失,强制模型学会“该输出几个点、每个点在哪”。
视角引导部分更有意思。作者观察到,同样一个人头,在图像近处(图像下方)占的像素多,在远处(图像上方)占的像素少。如果在特征提取阶段就让模型知道这个尺度变化规律,理论上能提升预测精度。具体做法是用一个视角图(perspective map)来建模,这个图每个像素的值表示该位置“一个标准大小的人”所占据的相对尺度。训练时把视角图作为辅助输入或者特征调制信号,让网络在回归点坐标时感知到当前区域的尺度信息。
这套设计的直接收益是:不再需要生成密度图,训练管线更干净;点级监督天然适合密集场景;推理时直接输出点坐标,后处理极简单。代价是:匈牙利匹配和集合预测的训练技巧要求较高,超参数敏感,复现时如果某个细节不对,效果会掉得很厉害。
1.3 为什么要找源码而不是自己从零实现
我在决定复现之前其实尝试过自己照着论文写一版,写到损失函数部分就卡住了。原因很简单:论文里对损失函数只写了一个公式,但实际代码里还包含匹配策略、正负样本分配、辅助损失等多个模块,单靠论文很难还原出全部细节。
后来我直接去GitHub上找到了官方仓库,发现它的代码结构比一般论文代码复杂不少:用了可变形注意力(deformable attention)、多尺度特征融合、匈牙利匹配、EMA等一堆机制。如果完全自己写,光是让这些模块正确对齐都不是一周能搞定的。所以我强烈建议,复现的第一步是先找到官方实现,通读代码搞清楚每个环节的逻辑,再考虑是否要自己从头写。这个项目的官方仓库是公开的,按论文标题搜就能找到。
2. 环境配置与数据准备
2.1 硬件与依赖版本选择
先说硬件。P2P这个模型主体是ResNet-50加Transformer解码器,显存占用比普通CNN大不少。我实测下来,输入分辨率是1024×1024时,单卡训练batch size设为4,显存占用大约在16GB左右,也就是说一块RTX 3080 Ti(12GB)比较勉强,2080 Ti(11GB)也不行,最好上3090(24GB)或者A5000以上的卡。如果只有小显存显卡,建议把输入分辨率降到768×768,代价是最终指标会下降一些,但至少能跑通流程。
依赖版本方面,官方README推荐的是Python 3.7、PyTorch 1.8以上、CUDA 10.2以上。我自己实测的配置是:Python 3.9、PyTorch 1.12.1、CUDA 11.3,可以正常跑通。需要注意的是,项目里用到了mmcv的一个自定义算子,用来做可变形注意力,这个对版本特别敏感。我用mmcv-full 1.6.1可以正常编译,换成mmcv 2.0之后直接报错,所以建议严格按官方要求来,不要盲目升级。
注意:这个项目最烦人的地方就是mmcv版本锁死。如果你之前装过其他版本的mmcv,建议单独建一个conda环境,不然光是算子编译就会浪费你半天时间。
2.2 数据集下载与目录结构整理
P2P主要在三个数据集上做实验:ShanghaiTech Part A、ShanghaiTech Part B、UCF-QNRF。我用的是ShanghaiTech Part A,这个数据集规模适中,训练集有300张图,标注是MAT格式的。先说下载,ShanghaiTech在百度网盘和Google Drive上都能搜到,网上有人整理过直接可用的链接。下载后是一个zip包,解压后包含images和ground_truth两个文件夹,ground_truth里每个mat文件对应一张图。
官方仓库的代码里,数据路径是在配置文件和dataset代码里写死的,默认目录结构大概是这样的:
data/ shanghaitech/ part_A_final/ train_data/ images/ ground_truth/ test_data/ images/ ground_truth/我第一次运行就是因为目录结构不对,导致数据集加载时一直报找不到文件。所以建议先按这个结构把文件放好,然后再改配置文件里的root路径,而不是反过来乱改代码。
另外还要注意一个细节:ShanghaiTech的标注mat文件里,坐标是[x, y]格式,但不同版本的数据集可能有人帮你转成了[y, x],如果训练时发现loss异常高,可以先检查一下数据加载部分的坐标读取逻辑,对比标注和图像上的实际位置。
2.3 环境配置的几个大坑
环境配置这块我踩了至少三个大坑,每个都花了不少时间排查。
第一个坑是mmcv自定义算子的编译。P2P用到了可变形注意力,这部分在推理时需要调用CUDA自定义算子,编译过程很慢,而且对gcc版本有要求。如果编译报错,先查一下gcc版本,Ubuntu 18.04自带的gcc 7.3可以,但gcc 9在某些场景下会编译失败。可以尝试用export CC=gcc-7指定编译器版本。
第二个坑是PyTorch和CUDA版本不匹配。我一开始用PyTorch 1.13 + CUDA 11.7,结果mmcv编译通过,但在运行时出现了“CUDA error: no kernel image is available”的错误。后来换成PyTorch 1.12.1 + CUDA 11.3,这个问题就消失了。所以如果遇到类似报错,第一反应不是重装mmcv,而是检查PyTorch和CUDA的匹配关系。
第三个坑是数据增强参数。原论文在训练时使用了随机裁剪、水平翻转、色彩抖动等增强,但不同数据集的最佳增强参数不一样。官方代码里默认参数是针对UCF-QNRF调过的,直接用在ShanghaiTech上效果并不是最优。后面在第4.2节我会给出一组我实测下来在ShanghaiTech Part A上更好的配置。
3. 核心代码结构与关键模块拆解
3.1 仓库目录结构梳理
把官方仓库clone下来之后,第一件事不是急着跑,而是先把目录结构过一遍。P2P的代码结构大致是这样的:
configs/ # 训练和测试配置,不同数据集对应不同config datasets/ # 数据集加载逻辑,包括标注解析、数据增强 models/ # 模型定义,包括backbone、encoder、decoder tools/ # 训练、测试、评估的入口脚本 mmdet/ # 依赖的mmdetection基础模块看代码的先后顺序建议是:先看configs,确认数据路径、模型配置、训练超参;再看datasets,搞清楚数据是怎么加载和增强的;最后看models,重点看decoder里的点回归头是怎么设计的。
很多复现者一上来就跑训练脚本,报错之后才开始看代码,这样效率很低。建议先花半小时把模型结构打印出来,确认每个模块的参数数量,再开始训练。可以用torchsummary或者直接打印模型的子模块。
3.2 模型主体:从图像到密度图和点坐标
P2P的整体流程可以拆成四步:骨干网络提特征、编码器融合多尺度、解码器做集合预测、后处理输出坐标。
骨干网络用的是ResNet-50,输出C3、C4、C5三个尺度的特征图,分别对应输入图像的1/8、1/16、1/32分辨率。然后这些特征会进入一个类似FPN的结构做多尺度融合,得到统一分辨率的特征图。这里的实现细节是:P2P没有完全照搬FPN,而是把不同尺度的特征上采样到同一分辨率后concat起来,再接一个1×1卷积降维,这样的做法能保留更多细节信息。
编码器部分使用了可变形注意力模块(Deformable Attention)。很多人第一次看DETR系代码会在这里卡住,因为可变形注意力和标准注意力最大的区别是:标准注意力对所有位置做加权,可变形注意力只对采样点做加权。采样的位置是网络自己预测的偏移量,这样计算量大幅降低,同时能自适应地关注到人头密集的区域。P2P在这个模块里还额外引入了视角图,通过一个简单的位置编码方式把视角信息注入到注意力计算中。
解码器部分是最核心的。它也是Transformer的decoder结构,但query的初始化方式比较特殊:不是用learned embedding,而是用骨干网络输出特征图上的网格点。每个网格点对应一个候选点,解码器通过多层注意力逐步细化这些点的坐标和置信度。这个设计的一个重要细节是,网格点的数量和密度直接决定了模型的容量上限,配置里默认是每张特征图定义一个网格密度,比如32×32的网格就对应最多1024个候选点。
3.3 损失函数与训练循环
P2P的损失函数分为三部分:分类损失、回归损失、辅助损失。
分类损失负责判断每个预测点是“正样本”(匹配到真实人头)还是“负样本”。这里用的是Focal Loss,因为候选点里绝大多数是负样本,Focal Loss能缓解正负样本不平衡。
回归损失只在正样本上计算,负责让预测坐标尽量靠近真实坐标。这里用L1 Loss,但是加了一个尺度归一化处理,因为不同位置的人头尺度差异很大,直接用L1会让远处小目标学习不充分。
辅助损失则是对解码器每一层的输出都计算一次损失,类似DETR里的auxiliary loss,帮助梯度回传和训练收敛。训练循环里还有一个细节是EMA(指数移动平均),模型保存的是参数的滑动平均版本,推理时用EMA参数精度会更高一点。
训练时最关键的超参数有三个:学习率、损失权重、训练轮数。官方config里默认学习率是1e-4,损失权重分类和回归是1:1,训练轮数在ShanghaiTech上是80或100轮。但我实测下来,直接跑100轮的收敛效果并不好,主要原因是在第60轮左右学习率会降一个量级,这个下调整点如果和数据集规模不匹配,就会导致后续收敛变慢。调整方案在第4.2节详述。
3.4 推理后处理:怎么从热图还原坐标
推理阶段比训练简单很多,但也有几个需要注意的细节。模型输出的是每个候选点的置信度和坐标偏移,后处理需要做两件事:一是过滤置信度低于阈值的点,二是对保留下来的点做NMS去重。
置信度阈值在官方代码里默认是0.2,但我实测在ShanghaiTech Part A上这个值偏低,会输出很多假阳性点,建议调到0.35左右。NMS的IOU阈值默认是0.5,如果人群特别密集,可以适当降低到0.4,减少重叠点的保留。这里有一个关键点要提醒:NMS是在“预测坐标周围一定范围内”去重,而不是对整张图的所有点做全局NMS,这样能保证密集区域的小目标点不被误删。
后处理还有一个细节是坐标还原。模型输出的坐标是相对于输入图像分辨率归一化后的值,需要乘以输入图像的实际宽高才能得到原图坐标。如果推理时做了padding或者resize,记得要在还原坐标时做对应的逆变换,否则可视化时会发现点和人头对不上。
4. 训练与评估的实操记录
4.1 训练命令与超参数设定
在正式训练前,建议先用官方提供的预训练权重跑一遍推理,确认环境和数据都没有问题,再开始从零训练。预训练权重的下载链接在仓库README里有,下载后放到checkpoints目录下。推理命令一般是这样的:
python tools/test.py configs/shanghaitech_a.py checkpoints/p2p.pth --eval mae如果测试能正常输出MAE指标,说明环境和数据都没问题。接下来就是训练流程。训练命令是:
python tools/train.py configs/shanghaitech_a.py训练之前要重点确认config里的这几个参数:
data_root:指向数据集根目录batch_size:根据显存调整,默认是4,显存不够就改成2,同时降低base_lrbase_lr:默认1e-4,batch_size减半时建议也减半,因为线性缩放规则在Transformer模型上比较有效max_epochs:官方默认80,实测建议改到100配合后续的学习率调整perspective_map:是否启用视角图,默认是True,这一点务必不要关掉
我自己实测下来,在单卡3090上训练80轮大约需要18~20个小时。如果是100轮,大约需要24小时。如果用的卡比较弱,可以先训练20轮看看loss下降趋势是否正常,再决定是否跑完整流程。
4.2 日志怎么看:损失、MAE、RMSE
训练过程中,日志会输出当前的迭代数、损失值、学习率等。第一次跑的时候我看到loss从1.2降到0.8就以为可以了,结果评估时MAE高达200多,完全不可用。后来检查代码发现,日志里的loss包括了三个部分的总和,就算回归损失已经收敛,辅助损失和分类损失的波动也会让总loss看起来不够美观。所以更可靠的判断方式是:单独看回归损失(回归头输出的L1 loss)是否在稳定下降,同时看分类损失是否降到0.1以下。
训练完以后,评估脚本会输出两个指标:MAE(平均绝对误差)和RMSE(均方根误差)。MAE的含义是预测人数和真实人数的平均差距,RMSE对大误差更敏感。在ShanghaiTech Part A上,原论文报告的MAE大概是76左右。我这里改了几个配置之后最终MAE是82.5,虽然和原论文还有一点差距,但对于个人复现来说已经算是不错的结果了。差距主要来自训练资源的限制——原论文用了8张卡,我只有1张卡,batch size明显不足。
如果你想尽量逼近论文指标,这里有一个我踩了很多坑之后总结的调整方案:
| 参数 | 官方默认值 | 我的调整值 | 调整原因 |
|---|---|---|---|
| base_lr | 1e-4 | 8e-5 | batch size减小后适当降lr,避免训练震荡 |
| max_epochs | 80 | 100 | 让模型充分收敛 |
| lr_schedule | step60 | step70之后降一次 | 延后学习率下降点,和训练轮数匹配 |
| conf_thr | 0.2 | 0.35 | 降低密集场景假阳性 |
| crop_size | 1024 | 1024 | 保持原论文设置 |
| loss_weight_cls | 1.0 | 1.0 | 保持不变 |
4.3 评估阶段的关键细节
评估阶段除了看MAE和RMSE这两个数值,我强烈建议把预测结果可视化出来看一眼,光看数值很容易被“平均”骗过去。我在第一次评估时MAE是95,看起来还能接受,但可视化之后发现模型在人群特别密集的区域严重漏检,在大片空白区域又会误检。这种问题只看MAE是发现不了的。
可视化方法很简单:加载测试脚本里保存的预测结果,把置信度大于阈值的点画到原图上。如果发现密集区域的点明显比标注少,说明模型的召回率不够,可以尝试把NMS的IOU阈值调低;如果发现空白区域有大量假阳性点,说明置信度阈值偏低,继续调高。
还有一个细节是评估时的输入分辨率。P2P在测试时会把输入图像resize到固定分辨率,这个分辨率对最终结果影响很大。官方默认是1024,我试过768和1280,发现768会掉点很多(MAE从82掉到95),1280会涨一点(MAE约80),但显存占用和推理时间都明显上升。如果显存允许,建议测试时用1280分辨率,推理时间多两三秒,但精度确实更好。
5. 常见问题与排查记录
5.1 显存不够怎么处理
显存不够是最常见的问题,表现是训练一开始就报"CUDA out of memory"。解决思路主要有四个:
- 降低batch size,从4降到2或1,同时按比例降低学习率
- 降低输入分辨率,从1024降到768,但做好MAE上升3~5个点的心理准备
- 开启梯度累积,保持较大有效batch size的等效效果:
optimizer_config = dict(grad_clip=None, type='GradCumulativeOptimizerHook', cumulative_iters=2) - 使用混合精度训练,在config里设置
fp16 = dict(loss_scale=512.)
这四个方法里,优先推荐混合精度。因为P2P的骨干网络是ResNet-50,对FP16的宽容度很高,loss基本不受影响。但如果同时开启FP16和数据增强,某些增强操作(如随机裁剪)可能会出现数值异常,需要单独验证。
5.2 数据加载慢与预处理冲突
训练时如果发现GPU利用率很低,经常在30%以下徘徊,问题大概率出在数据加载上。ShanghaiTech的高清图像很大,JPEG解码瓶颈和标注解析都会影响速度。我的解决办法是:
- 把数据提前预处理成h5py或lmdb格式,避免每次训练都重新解析mat文件
- 把num_workers从默认4提高到8,但要先确认内存够用,不然会导致OOM
- 启用pin_memory,设置
persistent_workers=True,减少每个epoch启动worker的开销
另外有个数据增强的坑:官方代码里有一个随机裁剪操作,是从原图随机裁出512×512的区域用于训练,但如果数据集里某些图像本身分辨率小于512,会在解码阶段直接报错。我之前跑到第37轮突然报错,花了好久才发现是一张分辨率异常的小图导致的。排查方法是写一个脚本遍历所有训练图像尺寸,把小于裁剪尺寸的图片过滤或特殊标记处理。
5.3 复现效果与原论文对不齐
这是复现类项目最常见的疑惑:明明按照官方代码跑,为什么指标总是差几个点?先说结论:除非你完全复刻原论文的硬件、batch size、训练时长和随机种子,否则指标一定会有一个正常波动范围。P2P这种集合预测类模型对batch size特别敏感,原论文的batch size可能是我单卡情况的4~8倍,这个差距不是调整任何超参数能完全弥补的。
另一个导致指标偏差的因素是随机种子。官方代码里没有固定种子,导致每次训练结果都不一样。我在同一份数据上跑了两次,MAE分别是82.5和85.1。所以如果你跑出了86左右的结果,别急着怀疑自己哪里写错了,多跑一次看看方差。
如果和论文差距特别大(比如超过10个点),重点排查这几个地方:
| 检查项 | 错误表现 | 排查方法 |
|---|---|---|
| 坐标读取顺序 | 标注点全部偏移 | 可视化比对标注点和图像位置 |
| 视角图是否启用 | 模型完全没学到尺度信息 | 打印config确认perspective_map=True |
| 数据增强是否生效 | loss下降极快但指标很差 | 把增强全部关闭,对比loss曲线 |
| 权重初始化方式 | 训练不稳定 | 检查backbone是否加载了ImageNet预训练权重 |
| EMA参数是否用于推理 | 指标略差但可控 | 确认测试时读取的是ema模型 |
5.4 快速自查清单
最后给一份我自己复盘时用的自查清单,每次实验跑完都对照一遍,能省去很多排查时间:
- 数据路径和目录结构是否正确,训练集和测试集有没有放反
- 坐标读取顺序和图像坐标是否一致,尤其是x和y有没有反
- 模型是否加载了正确的预训练权重,有没有加载错成其他数据集的权重
- config里的训练超参是否和当前batch size匹配,学习率是否过大或过小
- 训练完成后是否使用了EMA权重做评估,测试脚本是否默认保存了EMA参数
- 后处理时的置信度阈值和NMS参数是否合适,不要直接使用默认值不加验证
- 可视化结果中,预测点和标注点是否能大致对齐,是否存在系统性偏移
这七条里,第4条最容易忽略,第7条最能发现问题。我最后一次把MAE从85降到82.5,就是因为可视化后发现模型在图像边缘区域有明显的系统偏移,排查后发现是测试时的resize方式导致坐标还原偏差,修正之后就对了。
说到底,复现工作真正有价值的不是“跑通”,而是通过跑通建立对整套方法每个细节的掌控感。P2P这个项目我前后跑了两周,中间一度怀疑官方代码有问题,最后发现都是自己配置或理解的问题。如果你也在复现过程中卡在某个报错上,我的建议是先别急着改代码,把报错信息、模型结构、数据流三个点梳理清楚,90%的问题都能自己解决。等这套流程走顺了,再去回头跳读论文里的公式,你会发现很多之前觉得抽象的描述一下子就通了。