简介:本资源是CVPR2019论文《用于RGB-D人群计数和定位的密度图回归引导检测网络》的PyTorch实现,面向具备Python与深度学习基础、从事人群计数或RGB-D视觉研究的学生与开发者,帮助复现并理解密度图回归与检测网络融合的完整方案。包内共594个文件,以275个Python源码与232个pyc编译文件为主体,另含9个C++、7个CUDA与1个Cython文件用于可变形卷积、ROI对齐、NMS等自定义算子,配合24个Markdown说明、6个Shell脚本及若干配置与编译文件,压缩包约12.38MB。资源附带ShanghaiTechRGBD数据集目录说明,涵盖训练图像、深度mat与真值,以及测试图像、深度与边界框标注,并解释深度度量单位等预处理细节。已有767人学习,适合作为RGB-D人群计数与定位方向的复现与二次开发参考。
1. RGBD人群计数到底比RGB多解决了什么
RGBD人群计数,说白了就是在普通彩色图像之外,再叠一路深度图,让模型不光知道“这里有人”,还知道“这个人离镜头多远、和旁边人是不是挤在同一层”。我第一次在密集场景里跑纯RGB模型时,画面里人头攒动,模型把远处一排人误判成背景纹理,近处几个人又被合并成一个点,误差直接翻倍。后来加了深度通道,同一张图里前后景被拉开,模型才分得清谁是谁。
这个方向适合两类人:一类是做安防、客流统计、公共空间管理的工程师,现场有深度相机或双目模组,想提升密集场景精度;另一类是做多模态融合的研究者,想找一个输入结构清晰、评价指标明确的落地任务。它解决的核心问题是遮挡和尺度歧义——RGB给纹理,深度给几何,两者互补。但别指望随便拿个深度图就能涨点,深度质量、对齐误差、融合方式,每一步都能让效果翻车。
2. 从RGB到RGBD:数据管线怎么搭才不白干
2.1 深度图从哪来,选主动双目还是ToF
常见深度来源有三类:结构光、ToF、双目立体匹配。结构光在近距离精度高,但室外强光下基本废掉;ToF响应快、中远距离稳,但多机同开容易互相干扰;双目靠纹理匹配,成本低,弱纹理区域容易出空洞。我一般会先看场景:室内固定机位、人群密度高,优先ToF;室外或大范围,双目加补光更实际。
拿到深度图后,第一件事不是急着喂网络,而是检查无效值比例。深度图里0值或极大值通常代表测距失败,直接当特征输入会让模型学到一堆噪声。常见做法是设一个有效范围,比如0.5米到8米,超出范围置零,再单独算一个有效掩码通道。这个掩码本身也是有用特征,告诉网络哪些位置可信。
import numpy as np def clean_depth(depth, min_d=0.5, max_d=8.0): # depth: HxW float32, 单位米 valid = (depth > min_d) & (depth < max_d) depth_clean = np.where(valid, depth, 0.0) # 归一化到0-1,避免量纲差异 depth_norm = (depth_clean - min_d) / (max_d - min_d) depth_norm = np.clip(depth_norm, 0, 1) mask = valid.astype(np.float32) return depth_norm, mask这段代码做了三件事:过滤无效深度、归一化、生成有效掩码。参数min_d和max_d要根据实际相机标定结果调,别照搬。归一化后深度值落在0到1之间,和RGB的0到255量纲差太多,后面融合时要么把RGB也归一化,要么给深度单独做BN。
2.2 对齐:RGB和深度不对齐,后面全白搭
RGB和深度来自不同传感器,视场角和分辨率往往不一致。常见做法是用标定参数把深度图重投影到RGB视角,生成对齐后的深度图。这一步没做好,网络看到的“深度”和“颜色”是错位的,融合反而引入噪声。
我一般会先做棋盘格标定,拿到内参和外参,再用cv2.rgbd.registerDepth或自己写重投影。对齐后检查边缘:拿一个明显前景物体,看它在RGB和深度里的轮廓是否重合。如果差几个像素,密集场景里就是几个人头的偏移。
import cv2 import numpy as np def align_depth_to_rgb(depth, rgb, depth_intrin, rgb_intrin, extrinsics): # depth: 原始深度图, rgb: 彩色图 # depth_intrin/rgb_intrin: 各自内参 # extrinsics: 深度到RGB的外参 h, w = rgb.shape[:2] depth_aligned = np.zeros((h, w), dtype=np.float32) # 生成RGB每个像素对应的深度采样点 for v in range(h): for u in range(w): # 反投影到深度相机坐标系,再投影到深度图 # 实际工程用向量化,这里示意逻辑 pass # 工程上直接用cv2.rgbd.registerDepth或重投影矩阵 return depth_aligned实际工程不会用双重循环,而是构造重投影矩阵,用cv2.remap一次性完成。参数extrinsics的平移向量单位要和深度一致,旋转矩阵别搞反。对齐后建议再做一个中值滤波,去掉重投影产生的孤立噪点。
2.3 标注:点标注还是框标注,深度怎么标
人群计数主流用点标注:每个人头一个点,生成密度图。RGBD场景下,点标注还要带深度信息。常见做法是给每个点附一个深度值,或者按深度分层生成多通道密度图。我一般会按深度分3到5层,每层一张密度图,最后通道拼接。这样网络能学到“近处人多”和“远处人多”的不同模式。
标注工具方面,用现成的点标注软件,导出JSON,再写脚本转成密度图。深度值从对齐后的深度图里采样,取点周围小窗口的中值,避免单点噪声。如果深度缺失,用邻近有效值插值,别直接填0。
import json import numpy as np from scipy.ndimage import gaussian_filter def points_to_density(points, depth_map, h, w, sigma=4, num_layers=3): # points: list of (x, y) # depth_map: 对齐后深度图 # 按深度分层 densities = np.zeros((num_layers, h, w), dtype=np.float32) for (x, y) in points: d = depth_map[int(y), int(x)] if d <= 0: continue layer = min(int(d / (8.0 / num_layers)), num_layers - 1) densities[layer, int(y), int(x)] += 1 for i in range(num_layers): densities[i] = gaussian_filter(densities[i], sigma=sigma) return densitiessigma控制密度图平滑程度,人头小就调小,人头大就调大。num_layers不是越多越好,3到5层够用,太多层每层样本少,训练不稳。分层边界按实际深度分布定,别均匀切。
3. 网络怎么改:把深度塞进计数模型的三种姿势
3.1 早期融合:双流 backbone 加通道拼接
最直接的做法是RGB和深度各走一个backbone,在某个阶段拼接特征。我一般用ResNet-50的前几层分别提RGB和深度,然后在stage2或stage3拼接,后面共享。深度图是单通道,直接复制成三通道喂给标准backbone,或者改第一层卷积输入通道为1。
import torch import torch.nn as nn from torchvision.models import resnet50 class EarlyFusion(nn.Module): def __init__(self): super().__init__() rgb_backbone = resnet50(pretrained=True) depth_backbone = resnet50(pretrained=True) # 改深度第一层为单通道 depth_backbone.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) self.rgb_stem = nn.Sequential(*list(rgb_backbone.children())[:5]) self.depth_stem = nn.Sequential(*list(depth_backbone.children())[:5]) # 拼接后通道翻倍,用1x1卷积压回 self.fuse = nn.Conv2d(1024, 512, kernel_size=1) self.head = nn.Sequential( nn.Conv2d(512, 256, 3, padding=1), nn.ReLU(), nn.Conv2d(256, 1, 1) ) def forward(self, rgb, depth): f_rgb = self.rgb_stem(rgb) f_depth = self.depth_stem(depth) f = torch.cat([f_rgb, f_depth], dim=1) f = self.fuse(f) return self.head(f)早期融合的优点是实现简单,深度和RGB在浅层就交互。缺点是深度图噪声会直接污染RGB特征,而且两个backbone参数量大。我一般会在深度分支加一个轻量注意力,让网络自己决定信多少深度。
3.2 中期融合:注意力引导的跨模态加权
中期融合不直接拼特征,而是用注意力算一个权重图,告诉网络每个位置该信RGB还是信深度。常见做法是各自提特征后,算一个通道注意力或空间注意力,再加权求和。
class MidFusion(nn.Module): def __init__(self, channels=512): super().__init__() self.att = nn.Sequential( nn.Conv2d(channels * 2, channels, 1), nn.ReLU(), nn.Conv2d(channels, 1, 1), nn.Sigmoid() ) def forward(self, f_rgb, f_depth): # f_rgb, f_depth: B x C x H x W cat = torch.cat([f_rgb, f_depth], dim=1) w = self.att(cat) # B x 1 x H x W fused = w * f_rgb + (1 - w) * f_depth return fused这个权重图是逐像素的,密集场景里近处深度可靠,权重偏向深度;远处深度噪声大,权重偏向RGB。训练时不用额外监督,让计数损失反传即可。注意初始化时把最后一层bias设成正数,让初始权重偏向RGB,避免深度噪声一开始就主导。
3.3 后期融合:双分支各自出密度图再合并
后期融合让RGB和深度各自出一个密度图,最后按像素取平均或加权。这种做法鲁棒性好,一个模态崩了另一个还能撑。但缺点是两个分支独立,没有交互,深度信息利用不充分。
我一般会做加权融合,权重由一个小的门控网络根据局部特征生成。门控网络输入是RGB和深度的局部块,输出两个权重。训练时加一个一致性损失,让两个分支的密度图在有效区域尽量接近,减少分歧。
class LateFusion(nn.Module): def __init__(self): super().__init__() self.rgb_head = nn.Conv2d(512, 1, 1) self.depth_head = nn.Conv2d(512, 1, 1) self.gate = nn.Sequential( nn.Conv2d(1024, 64, 3, padding=1), nn.ReLU(), nn.Conv2d(64, 2, 1), nn.Softmax(dim=1) ) def forward(self, f_rgb, f_depth): d_rgb = self.rgb_head(f_rgb) d_depth = self.depth_head(f_depth) g = self.gate(torch.cat([f_rgb, f_depth], dim=1)) out = g[:, 0:1] * d_rgb + g[:, 1:2] * d_depth return out, d_rgb, d_depth一致性损失用L2,只在深度有效掩码内算。门控网络学习率可以设小一点,避免权重震荡。
4. 训练与评估:损失函数、指标和深度有效性处理
4.1 密度图损失:MSE、MAE和贝叶斯损失的取舍
人群计数常用MSE或MAE直接回归密度图。MSE对密集区域敏感,容易过平滑;MAE对稀疏区域更稳。我一般用组合损失:MSE加0.1倍MAE,再叠一个贝叶斯损失处理标注噪声。贝叶斯损失把每个点看作高斯分布,对点标注的微小偏移更鲁棒。
def bayesian_loss(pred, points, sigma=4): # pred: B x 1 x H x W # points: list of (x, y) per image loss = 0 for b in range(pred.shape[0]): for (x, y) in points[b]: # 在点周围采样,算负对数似然 pass return loss实际实现用高斯核卷积点图生成目标密度图,再算MSE。贝叶斯损失在点密集时计算量大,可以只对部分点采样。
4.2 评估指标:MAE、MSE和深度分层误差
标准指标是MAE和MSE,按整图计数算。RGBD场景下我还会加一个深度分层MAE:按深度把图分成近、中、远三层,分别算MAE。这样能看出模型是不是只学会了近处,远处全靠猜。
| 指标 | 含义 | 关注点 |
|---|---|---|
| MAE | 平均绝对误差 | 整体计数偏差 |
| MSE | 均方误差 | 大误差惩罚 |
| 近层MAE | 0.5-2米 | 近处遮挡 |
| 中层MAE | 2-5米 | 主要人群 |
| 远层MAE | 5-8米 | 深度噪声 |
评估时深度无效区域要排除,否则远层MAE会被0值拉偏。常见做法是只统计有效掩码内的像素。
4.3 深度有效性掩码怎么参与训练
深度掩码不只是预处理,还要进网络。我一般把掩码作为一个额外通道拼在深度图后面,变成两通道输入。损失函数里,无效区域的密度图预测不参与反传,或者给一个很小的权重。
def masked_mse(pred, target, mask): # mask: 1有效,0无效 diff = (pred - target) ** 2 diff = diff * mask return diff.sum() / (mask.sum() + 1e-6)掩码要跟密度图同分辨率,对齐后做下采样时用最近邻,别用双线性,否则边缘会糊。
5. 避坑与排查:RGBD计数里最容易翻车的五件事
5.1 深度图全黑或大面积空洞
现象:训练loss不降,预测密度图几乎全零。原因:深度相机在弱纹理或强光下失效,或者对齐时把有效区域裁掉了。解决:先可视化深度图,统计有效像素比例,低于30%就换相机或补光;对齐后检查边缘,别把有效区域裁没。
5.2 融合后精度反而比纯RGB低
现象:加了深度,MAE涨了。原因:深度噪声大,早期融合把噪声灌进RGB分支。解决:改用中期注意力融合,或者给深度分支加一个可学习的缩放因子,初始设0,让网络自己决定用多少深度。
5.3 密度图在远处出现大量虚警
现象:远处背景被预测成人头。原因:深度远处噪声大,网络把噪声当纹理。解决:在损失里给远处像素降权,或者按深度分层训练,远层单独调阈值。
5.4 训练集和测试集深度分布不一致
现象:测试集MAE远高于训练集。原因:训练集深度范围0.5-5米,测试集到8米,网络没见过远层。解决:训练时做深度增强,随机缩放深度范围,或者按深度分层采样,保证每层都有样本。
5.5 对齐误差导致人头错位
现象:预测密度图的人头位置和RGB对不上。原因:标定参数不准,或者重投影矩阵用错。解决:用棋盘格重新标定,检查重投影误差,对齐后拿一个明显物体验证轮廓重合度。
6. 进阶技巧:用深度做伪标签和跨场景迁移
深度信息除了当输入,还能做伪标签。比如在一个标注稀缺的新场景,先用纯RGB模型出粗密度图,再用深度图做区域分割,把同一深度层的人头合并,生成更干净的伪标签。我一般会设一个深度阈值,同一层内密度图做连通域分析,去掉孤立小区域,再当伪标签微调模型。
跨场景迁移时,深度比RGB更稳定。不同场景颜色分布差异大,但深度分布相对一致。常见做法是冻结深度分支,只微调RGB分支和融合层。这样新场景只需要少量标注就能适配。
def pseudo_label_refine(density, depth, layer_thresh=0.3): # density: HxW 粗密度图 # depth: HxW 归一化深度 # 按深度分层,每层内做连通域过滤 refined = np.zeros_like(density) layers = np.digitize(depth, bins=[0.33, 0.66]) for l in np.unique(layers): mask = (layers == l) & (depth > 0) d_layer = density * mask # 连通域分析,去掉面积过小的区域 from scipy.ndimage import label labeled, num = label(d_layer > 0.1) for i in range(1, num + 1): area = (labeled == i).sum() if area > 20: refined[labeled == i] = d_layer[labeled == i] return refined这个技巧的关键是深度分层边界要按实际场景调,别用固定值。伪标签质量决定微调效果,宁可少标,别把噪声当标签。
验证方法上,我习惯留一个深度分层验证集,每层至少50张图,单独看每层MAE。如果近层好远层差,说明深度利用不充分;如果近层差远层好,可能是RGB分支过拟合。调参时先动融合权重,再动损失权重,最后动backbone学习率。
踩过的坑里,最血泪的一条是:别在没对齐的数据上试融合。我早期图省事,直接拿原始深度和RGB拼,训练loss看着降,测试MAE爆炸。后来老老实实做标定和对齐,同样的网络结构,MAE降了将近三成。深度图不是万能药,它只是多了一个视角,对齐和有效性处理才是地基。希望帮到你。
本文还有配套的精品资源,点击获取