简介:本资源是CVPR2019论文《用于RGB-D人群计数和定位的密度图回归引导检测网络》的PyTorch实现,面向具备Python 3.x与PyTorch 1.1+基础、从事人群计数或RGB-D视觉研究的学生与开发者,帮助复现密度图回归与检测网络结合的算法流程。包内共594个文件,以275个py源码与232个pyc编译文件为主体,另含9个cpp、7个cu及9个so等底层扩展文件,配合24个md说明文档与若干sh、xml配置,压缩包约12.38MB。资源附带ShanghaiTechRGBD数据集目录结构说明,涵盖train_img、train_depth、train_gt及test_img、test_depth、test_bbox_anno等模块,并解释深度度量单位等预处理细节。已有767人学习,可据此搭建训练与测试环境,理解多模态数据组织方式,掌握密度图回归与定位任务的实现思路。
1. RGBD人群计数:为什么多一维深度,模型就不再集体翻车
RGBD人群计数,说白了就是在普通彩色图(RGB)之外,再喂给模型一张深度图(Depth),让网络同时看到“颜色纹理”和“远近几何”,从而把人群数量估得更准。做过纯RGB计数的人大概都有体会:商场玻璃幕墙、地铁反光地面、夜晚灯箱广告,这些场景里模型经常把倒影、海报、灯箱里的人脸一起数进去,误差能翻两三倍。RGBD人群计数的价值就在这——深度图天然过滤掉平面上的“假人”,因为倒影和真人不在同一个深度层。它适合谁?做安防监控、智慧交通、大型活动人流管理的算法工程师,以及手里有深度相机、想把手头RGB计数模型升级一档的团队。这一章先把“多一维深度到底改变了什么”讲清楚,后面再落到数据集、网络结构、训练参数和踩坑记录。
2. RGBD人群计数的数据从哪来:采集、对齐与标注的完整链路
2.1 深度相机选型与RGB-D对齐的硬约束
做RGBD人群计数,第一步不是写网络,而是把RGB和Depth对齐。常见深度相机分两类:结构光(近距离精度高,室外基本废)和ToF(飞行时间,室外可用但分辨率低)。我一般会先确认三件事:工作距离、深度分辨率、RGB与Depth的物理基线。基线越大,视差越大,对齐越难;基线太小,深度精度又不够。对齐的本质是把深度图重投影到RGB相机坐标系,需要内参和外参。内参用棋盘格标定,外参用同一棋盘格同时被RGB和Depth看到,解PnP。下面是一段用OpenCV做RGB-D对齐的最小代码,假设你已经标定好两套内参和一套外参。
import cv2 import numpy as np # 假设已标定:rgb_intrinsic 3x3, depth_intrinsic 3x3, R 3x3, T 3x1 # depth_map: 16位无符号,单位毫米;rgb_image: 8位三通道 def align_rgbd(rgb_image, depth_map, rgb_intrinsic, depth_intrinsic, R, T): h, w = rgb_image.shape[:2] # 生成RGB图像每个像素的归一化坐标 u, v = np.meshgrid(np.arange(w), np.arange(h)) uv1 = np.stack([u, v, np.ones_like(u)], axis=-1).reshape(-1, 3).T # 3xN # 反投影到RGB相机坐标系下的归一化射线 ray_rgb = np.linalg.inv(rgb_intrinsic) @ uv1 # 3xN # 变换到深度相机坐标系 ray_depth = R.T @ (ray_rgb - T.reshape(3, 1)) # 投影到深度图像素坐标 uv_depth = depth_intrinsic @ ray_depth uv_depth = uv_depth[:2] / uv_depth[2:3] # 归一化 map_x = uv_depth[0].reshape(h, w).astype(np.float32) map_y = uv_depth[1].reshape(h, w).astype(np.float32) # 把深度图重采样到RGB视角 aligned_depth = cv2.remap(depth_map, map_x, map_y, cv2.INTER_NEAREST) # 无效深度置0 aligned_depth[aligned_depth > 10000] = 0 return aligned_depth逻辑说明:先把RGB每个像素反投影成射线,再用外参转到深度相机坐标系,最后用深度内参投影回深度图像素坐标,得到重映射表。参数说明:R和T是深度相机到RGB相机的旋转和平移,标定顺序不能反;cv2.INTER_NEAREST必须用最近邻,否则深度边缘会出现插值出来的假深度。这一步做完,你手里才有一对像素级对齐的RGB-D图。
2.2 标注格式:点标注、框标注与密度图的取舍
人群计数的标注主流是点标注(每个人头一个点),因为框标注在密集场景下根本画不过来。点标注转密度图是标准流程:用高斯核在每个点处撒一个高斯斑,积分等于1。高斯核的sigma怎么定?常见做法是固定sigma(比如15像素),或者根据最近邻距离自适应。我一般先用固定sigma跑通,再换自适应看提升。下面是把点标注转密度图的代码。
import numpy as np from scipy.ndimage import gaussian_filter def points_to_density(points, img_shape, sigma=15): # points: list of (x, y) density = np.zeros(img_shape[:2], dtype=np.float32) for x, y in points: if 0 <= x < img_shape[1] and 0 <= y < img_shape[0]: density[int(y), int(x)] = 1.0 density = gaussian_filter(density, sigma=sigma) # 归一化,保证积分等于人数 if density.sum() > 0: density = density / density.sum() * len(points) return density逻辑说明:先在点位置置1,再高斯滤波,最后按总人数归一化。参数说明:sigma越大,密度图越平滑,模型学到的空间分布越模糊;sigma太小,密度图接近脉冲,训练时梯度不稳定。RGBD场景下,深度图可以辅助判断点的远近,但标注本身还是2D点,深度只作为输入通道。
2.3 数据集构建:同步采集与时间戳对齐
自己采数据时,RGB和Depth必须时间同步。常见做法是硬件触发,或者软件层面用时间戳最近邻匹配。如果相机不支持硬件同步,至少保证采集帧率一致,然后用时间戳对齐。我一般会写一个采集脚本,每帧同时存RGB、Depth和时间戳,后期按时间戳配对。注意:深度图有无效值(0或超大值),对齐后要把无效区域在RGB上标出来,训练时mask掉,否则模型会学到“黑色区域人数为0”这种错误关联。
3. 网络结构怎么选:双流编码、跨模态融合与密度回归
3.1 双流骨干:RGB与Depth分别编码再融合
RGBD人群计数的主流结构是双流:RGB走一个骨干(如VGG16前13层或ResNet前几层),Depth走另一个骨干,两个骨干可以是同构也可以是异构。同构的好处是预训练权重好迁移,异构的好处是Depth图通道少、纹理弱,可以用更轻的骨干。我一般先用同构双流跑baseline,再尝试Depth分支减半通道。融合位置很关键:早期融合(输入层拼接)会让RGB纹理污染Depth几何;晚期融合(最后拼接)又浪费了中间层的互补信息。常见做法是在骨干的stage3和stage4做跨模态注意力融合。
import torch import torch.nn as nn class CrossModalFusion(nn.Module): def __init__(self, channels): super().__init__() self.rgb_conv = nn.Conv2d(channels, channels, 1) self.depth_conv = nn.Conv2d(channels, channels, 1) self.attn = nn.Sequential( nn.Conv2d(channels * 2, channels, 1), nn.ReLU(inplace=True), nn.Conv2d(channels, channels, 1), nn.Sigmoid() ) def forward(self, rgb_feat, depth_feat): # 对齐空间尺寸 if rgb_feat.shape[-2:] != depth_feat.shape[-2:]: depth_feat = nn.functional.interpolate(depth_feat, size=rgb_feat.shape[-2:], mode='bilinear', align_corners=False) rgb_proj = self.rgb_conv(rgb_feat) depth_proj = self.depth_conv(depth_feat) # 通道注意力:用两路特征共同决定每个通道的权重 attn = self.attn(torch.cat([rgb_proj, depth_proj], dim=1)) fused = rgb_proj * attn + depth_proj * (1 - attn) return fused逻辑说明:两路特征先各自1x1卷积降维,再拼接后过一个小网络生成通道注意力,最后加权融合。参数说明:channels要和骨干对应stage的输出通道一致;注意力用Sigmoid保证权重在0到1之间。这个模块可以插在stage3和stage4之后,stage3融合偏细节,stage4融合偏语义。
3.2 密度图回归头:从特征到人数
回归头通常是一个1x1卷积把通道降到1,输出密度图,然后整个密度图求和得到人数。损失函数用MSE或者更稳的组合损失:MSE + 绝对误差。我一般用MSE为主,因为密度图是连续值,MSE梯度稳定。但MSE对稀疏区域不敏感,可以加一个SSIM损失提升局部结构。回归头之前可以加一个多尺度模块(类似ASPP),因为人群在深度方向上有远近,尺度变化大。
class DensityHead(nn.Module): def __init__(self, in_channels): super().__init__() self.aspp = nn.ModuleList([ nn.Conv2d(in_channels, 64, 3, padding=1, dilation=1), nn.Conv2d(in_channels, 64, 3, padding=3, dilation=3), nn.Conv2d(in_channels, 64, 3, padding=6, dilation=6), ]) self.fuse = nn.Conv2d(64 * 3, 64, 1) self.out = nn.Conv2d(64, 1, 1) def forward(self, x): feats = [conv(x) for conv in self.aspp] x = torch.cat(feats, dim=1) x = torch.relu(self.fuse(x)) density = torch.relu(self.out(x)) # 密度非负 return density逻辑说明:三个不同膨胀率的卷积捕捉不同尺度,拼接后融合,最后输出单通道密度图。参数说明:dilation越大,感受野越大,适合远距离人群;torch.relu保证密度非负,因为人数不可能为负。
3.3 训练策略:预训练、学习率与深度图归一化
训练时,RGB分支用ImageNet预训练,Depth分支要么用同样的预训练(把Depth复制成三通道),要么从头训。我一般先冻结Depth分支前几层,只训融合层和回归头,等loss稳定再解冻。学习率用1e-4到1e-5,Adam优化器。深度图归一化很重要:不同相机的深度范围差异大,我一般把深度图线性映射到0到1,无效值置0,再减均值除方差。注意:深度图的均值和方差要在训练集上统计,不能直接用RGB的。
4. 避坑与排查:RGBD人群计数里最容易翻车的五件事
4.1 深度图无效值被当成0深度
现象:模型在玻璃、水面、黑色物体区域预测人数异常高或异常低。原因:深度相机在这些区域返回0或超大值,对齐后这些像素在RGB上可能是真人,但深度通道是0,模型学成了“深度0等于没人”。解决:训练时生成一个有效mask,无效区域在损失里权重置0,或者把无效深度替换成该区域的中值深度。
4.2 RGB与Depth对齐误差超过3像素
现象:模型在人群边缘预测出双峰密度,人数偏多。原因:外参标定不准,或者采集时相机震动导致基线变化。解决:重新标定,采集时固定相机;对齐后用边缘重合度检查,误差超过3像素就重标。
4.3 深度图分辨率远低于RGB
现象:融合时深度特征上采样后模糊,模型性能不如纯RGB。原因:深度相机分辨率低(如320x240),RGB是1920x1080,直接上采样丢失几何细节。解决:在融合前把RGB下采样到深度分辨率,或者用可变形卷积做特征对齐,而不是简单双线性插值。
4.4 密度图高斯sigma与人群密度不匹配
现象:密集区域密度图过平滑,人数低估;稀疏区域过尖锐,人数高估。原因:固定sigma不适应尺度变化。解决:用自适应sigma,根据最近邻距离的k倍动态调整,或者用多尺度密度图监督。
4.5 训练集和测试集深度分布不一致
现象:在室内训练的模型,放到室外场景人数误差翻倍。原因:室内深度范围0.5到5米,室外可能5到50米,归一化后分布完全不同。解决:训练时做深度增强,随机缩放深度范围;或者用深度直方图匹配做域适应。
5. 进阶技巧:用深度先验做伪标签与跨场景微调
5.1 深度先验引导的伪标签生成
当你有一个标注好的RGBD数据集,但想迁移到没有深度标注的新场景时,可以用深度先验生成伪标签。具体做法:先用已训练模型在新场景RGB上预测密度图,再用深度图做一致性过滤——如果预测的高密度区域在深度图上对应的是平面(深度梯度小),大概率是倒影或海报,把这些区域的伪标签权重降低。下面是一个简单的深度梯度过滤代码。
import cv2 import numpy as np def depth_gradient_mask(depth_map, grad_thresh=0.1): # depth_map: 归一化到0-1,无效值为0 depth_map = cv2.GaussianBlur(depth_map, (5, 5), 0) grad_x = cv2.Sobel(depth_map, cv2.CV_32F, 1, 0, ksize=3) grad_y = cv2.Sobel(depth_map, cv2.CV_32F, 0, 1, ksize=3) grad_mag = np.sqrt(grad_x**2 + grad_y**2) # 梯度小的地方是平面,可能是假人 mask = (grad_mag > grad_thresh).astype(np.float32) return mask逻辑说明:对深度图做高斯平滑后求Sobel梯度,梯度幅值小说明该区域深度变化平缓,大概率是平面。参数说明:grad_thresh需要根据深度图归一化范围调整,我一般从0.1开始试。这个mask可以乘到伪标签的损失权重上。
5.2 跨场景微调:冻结融合层,只调回归头
从室内到室外,或者从低密度到高密度场景,全网络微调容易过拟合。我一般冻结双流骨干和融合层,只微调回归头,学习率降到1e-5,训练10到20个epoch。如果性能还不够,再解冻融合层,但骨干始终冻结。这样做的原因是骨干学的是通用纹理和几何,融合层学的是跨模态对齐,这两部分跨场景变化不大,回归头才是对密度分布最敏感的。
5.3 验证方法:用深度分层误差代替整体MAE
整体MAE会掩盖远近区域的差异。我一般把测试集按深度分成近、中、远三层,分别算MAE。如果近处误差小、远处误差大,说明模型对远距离小目标不敏感,需要加多尺度监督或者提高深度图远距离的分辨率。这个分层验证方法比只看一个总数有用得多,能直接告诉你问题出在哪个深度段。
我自己的习惯是:每次训完模型,先不看总MAE,先看深度分层误差和密度图可视化,确认没有把倒影数进去,再去看总数。这个习惯帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取