简介:Visdrone2021_CrowdCounting.rar 面向无人机视觉监控方向的研究者与算法工程师,聚焦复杂场景下的人群计数任务,属于 VisDrone2021 系列中的 Crowd Counting 专项。资源覆盖多种光照、天气与人群密度条件下的无人机视角图像,可用于训练与评测基于密度图估计、多尺度特征融合及 CNN、U-Net 等深度学习模型的人群计数方案。压缩包共 10854 个文件,以 7236 个 jpg 图像和 3616 个 xml 标注为主,另含 2 个 py 脚本,整体约 820.44MB,图像与标注一一对应,便于直接构建训练集与验证集。data_processing 模块提供预处理、标注分析与评估工具,可辅助完成图像增强、目标检测与计数指标计算。已有 1538 人学习,适合需要真实无人机视角数据开展实验、复现与算法优化的读者参考使用。
1. 从 Visdrone2021_CrowdCounting.rar 说起:无人机视角下的人群计数到底难在哪
无人机俯拍的人群计数,和地面监控里数人头完全不是一回事。地面摄像头视角低、人占像素多、遮挡关系简单;而无人机在 20 到 80 米高度俯拍,一个人可能只有 10 到 30 个像素高,密集场景里人头连成一片,边缘、纹理、颜色全都糊在一起。Visdrone2021_CrowdCounting.rar 这个标题指向的,就是基于 VisDrone 2021 数据集做人群计数(Crowd Counting)的一套工程方案。它要解决的问题很具体:给定一张无人机视角的航拍图,估计出画面里的人数,并尽量给出密度分布。适合谁?做安防巡检、大型活动人流监测、景区与交通枢纽人流评估的算法工程师,以及想拿航拍数据集练密度估计回归的开发者。这篇笔记按「数据怎么准备、模型怎么搭、训练怎么调、坑在哪」的顺序讲透,新手能照着跑,熟手能直接看参数边界。
2. 数据准备:把 VisDrone 标注转成密度图标签
人群计数模型绝大多数走的是密度图回归路线,而不是直接回归一个数字。原因很直接:直接回归人数,一张图只有一个监督信号,梯度太稀疏;密度图给每个像素都有监督,模型能学到「人往哪聚」的空间分布,泛化更稳。所以第一步不是搭网络,而是把 VisDrone 的检测框标注转成密度图标签。这一步做错,后面训练再花哨都是白费。
2.1 为什么用高斯核生成密度图而不是数框
VisDrone 的标注是目标检测格式,每行一个框,包含左上角坐标、宽高、类别、截断程度等字段。人群计数需要的是逐像素密度。常见做法是:把每个标注框的中心点当作一个人头位置,在该位置放一个归一化的二维高斯核,所有高斯核叠加,得到一张密度图,密度图所有像素求和约等于人数。
这里有个关键参数:高斯核的标准差 σ。σ 太小,密度图退化成一个个孤立的点,模型学到的是点检测而不是密度估计,遇到密集区域会崩;σ 太大,密度图过度平滑,人数估计系统性偏低。工程上的经验做法是按人头在图像中的平均尺寸自适应设定 σ,或者用固定 σ 加多尺度训练来兜底。VisDrone 里人头尺寸随飞行高度变化很大,我一般会先统计标注框高度的中位数,把 σ 设成这个中位数的 0.3 到 0.5 倍,再在验证集上看 MAE 微调。
2.2 标注解析与密度图生成的完整脚本
下面这段脚本做三件事:读 VisDrone 的标注文件、过滤掉无效类别和过小目标、生成密度图并保存。目录结构假设为data/VisDrone/annotations/和data/VisDrone/images/,实际路径按自己的解压结果改。
import os import numpy as np import cv2 from scipy.ndimage import gaussian_filter # VisDrone 类别里 0 表示忽略区域,1 表示 pedestrian,这里只保留行人 VALID_CLASSES = {1} MIN_BOX_H = 8 # 过滤高度小于 8 像素的框,噪声太大 SIGMA_RATIO = 0.4 # sigma = 框高中位数 * 该比例 def parse_annotation(txt_path): boxes = [] with open(txt_path, "r") as f: for line in f: parts = line.strip().split(",") if len(parts) < 8: continue x, y, w, h = map(float, parts[:4]) cls = int(parts[5]) if cls not in VALID_CLASSES or h < MIN_BOX_H: continue boxes.append((x, y, w, h)) return boxes def make_density_map(boxes, img_shape, sigma): h, w = img_shape[:2] density = np.zeros((h, w), dtype=np.float32) for (x, y, bw, bh) in boxes: cx, cy = int(x + bw / 2), int(y + bh / 2) if 0 <= cy < h and 0 <= cx < w: density[cy, cx] += 1.0 # 用高斯滤波把点扩散成密度分布,再归一化保证求和等于人数 density = gaussian_filter(density, sigma=sigma, mode="constant") total = density.sum() if total > 0: density = density / total * len(boxes) return density def build_dataset(ann_dir, img_dir, out_dir): os.makedirs(out_dir, exist_ok=True) for name in os.listdir(ann_dir): if not name.endswith(".txt"): continue stem = name[:-4] img_path = os.path.join(img_dir, stem + ".jpg") if not os.path.exists(img_path): continue boxes = parse_annotation(os.path.join(ann_dir, name)) if len(boxes) == 0: continue img = cv2.imread(img_path) heights = [b[3] for b in boxes] sigma = max(2.0, np.median(heights) * SIGMA_RATIO) density = make_density_map(boxes, img.shape, sigma) np.save(os.path.join(out_dir, stem + ".npy"), density) if __name__ == "__main__": build_dataset("data/VisDrone/annotations", "data/VisDrone/images", "data/density")逻辑说明:parse_annotation里parts[5]是类别字段,VisDrone 的字段顺序是框坐标、宽高、类别、截断、遮挡,不同年份版本字段位置可能略有差异,跑之前先打印一行原始标注确认。make_density_map先把人头中心点累加到单通道图上,再用gaussian_filter扩散,最后按len(boxes)重新归一化,保证密度图求和严格等于人数——这一步不做,高斯滤波会改变总能量,标签和真实人数对不上,训练时 loss 会一直有偏。
参数说明:MIN_BOX_H是过滤阈值,VisDrone 里大量 5 像素以下的小框基本是误标或极远目标,留着只会让密度图出现毛刺;SIGMA_RATIO控制平滑程度,0.3 偏锐、0.5 偏糊,建议在 0.35 到 0.45 之间扫一遍。生成完建议随机抽 5 张密度图叠加到原图上肉眼检查,密度峰值是否落在人头位置,这一步能提前发现字段解析错位的问题。
2.3 训练集与验证集的划分边界
VisDrone 官方划分里,训练集和验证集来自不同场景序列。人群计数任务上,如果直接按官方划分,验证集场景和训练集差异大,MAE 会偏高但更真实;如果自己随机打乱划分,指标会好看很多,但那是自欺欺人。我的建议是:先用官方划分跑通,确认模型能收敛;做对比实验时再固定一套随机种子划分,保证不同模型之间可比。另外,VisDrone 里有些图人数极少(个位数),有些图几百人,密度差异极大,训练时按人数分桶采样,避免小图被大图梯度淹没。
3. 模型搭建:从 CSRNet 到轻量密度回归网络
数据准备好之后,模型选型决定了上限和落地成本。人群计数这个方向,CSRNet 是绕不开的基线:前端用 VGG16 的前若干层做特征提取,后端接一串空洞卷积扩大感受野,最后输出单通道密度图。它的好处是结构简单、复现容易、论文指标可对照;坏处是参数量大,无人机场景要部署到边缘设备时吃不消。所以实际工程里常见两条路:一是 CSRNet 做精度基线,二是自己搭轻量网络做部署。
3.1 CSRNet 结构拆解与关键层参数
CSRNet 的前端直接复用 VGG16 的 conv1 到 conv4,输出 stride 为 8 的特征图;后端是 6 层空洞卷积, dilation 依次为 2、2、2、2、2、2,感受野逐层叠加,保证在 stride 8 的特征图上仍能覆盖大范围人群。最后一层 1x1 卷积把通道压到 1,得到密度图。输入图像通常缩放到 768 或 1024 宽,密度图尺寸是输入的 1/8,训练时标签也要对应下采样。
这里有个容易翻车的点:CSRNet 原论文的密度图标签是下采样到 1/8 分辨率的,如果你用全分辨率密度图去监督 1/8 输出,尺寸对不上直接报错;如果强行 resize,密度总和会变,人数估计就偏了。正确做法是生成密度图时就在目标分辨率上做高斯扩散,或者对全分辨率密度图做 sum pooling 下采样(不是平均池化),保证下采样后求和仍等于人数。
3.2 一个可训练的轻量密度回归网络
部署向的方案我一般用「轻量骨干 + 多尺度融合 + 密度头」。骨干用 MobileNetV2 的前几层,输出 stride 8 特征;接一个轻量 ASPP 模块做多尺度上下文;最后 1x1 卷积出密度图。下面给出核心网络定义,PyTorch 实现。
import torch import torch.nn as nn import torchvision class DensityHead(nn.Module): def __init__(self, in_ch=128): super().__init__() self.conv1 = nn.Conv2d(in_ch, 64, 3, padding=1) self.conv2 = nn.Conv2d(64, 32, 3, padding=1, dilation=2) self.conv3 = nn.Conv2d(32, 1, 1) self.relu = nn.ReLU(inplace=True) def forward(self, x): x = self.relu(self.conv1(x)) x = self.relu(self.conv2(x)) return self.conv3(x) class LiteCounter(nn.Module): def __init__(self): super().__init__() backbone = torchvision.models.mobilenet_v2(weights=None).features # 取到 stride 8 的那一层,索引按 torchvision 版本可能不同,跑前打印确认 self.backbone = backbone[:14] self.head = DensityHead(in_ch=160) def forward(self, x): feat = self.backbone(x) # 输出 stride 8 density = self.head(feat) # 1 通道密度图 return density逻辑说明:backbone[:14]截取 MobileNetV2 到 stride 8 的位置,不同 torchvision 版本层索引会变,第一次跑先print(backbone)看输出尺寸,确认特征图是输入的 1/8 再固定索引。DensityHead里第二层用了 dilation=2,在不增加参数的前提下扩大感受野,缓解密集区域密度估计断裂的问题。输出没有接 ReLU,因为密度理论上非负,但训练初期允许小负值有助于收敛,推理时再 clamp 到 0。
参数说明:in_ch=160要和骨干输出通道对齐,MobileNetV2 在 stride 8 处是 160 通道,写错直接维度报错。输入尺寸建议固定为 512x512 或 768x768 的倍数,因为 stride 8 要求宽高能被 8 整除,否则最后特征图和标签尺寸差一两个像素,loss 计算会报 shape mismatch。
3.3 损失函数:MSE 之外为什么还要加感知损失
密度图回归最常用的是 MSE,但它对密集区域和稀疏区域一视同仁,导致模型在稀疏区域过度优化、密集区域欠拟合。工程上常见的改进是加一项基于局部计数的损失:把预测密度图和标签密度图都切成固定大小的块(比如 16x16),对每块求和得到局部人数,再对这些局部人数算 L1 或平滑 L1。这样模型在密集块上的误差会被放大,逼它把密集区域学准。两项损失的权重我一般设 MSE 为 1.0、局部计数损失为 0.1 到 0.5,具体看验证集 MAE 调。
4. 训练调参与评估:MAE、MSE 和那块不能省的验证集
训练人群计数模型,指标只看 MAE 是不够的。MAE 衡量平均绝对误差,MSE 对大误差更敏感,两个都要看。更重要的是分场景看:把验证集按人数分成稀疏(小于 50)、中等(50 到 200)、密集(大于 200)三档,分别算 MAE,你会发现模型往往在密集档崩得最厉害,而整体 MAE 被稀疏档拉得很好看。这个分档评估是判断模型能不能上线的关键。
4.1 训练循环与学习率策略
下面是一个最小可跑的训练循环,包含分档评估。优化器用 Adam,初始学习率 1e-4,余弦退火到 1e-6,batch size 按显存定,8 到 16 之间。
import torch from torch.utils.data import Dataset, DataLoader import numpy as np class DensityDataset(Dataset): def __init__(self, img_dir, den_dir, size=512): self.img_dir, self.den_dir, self.size = img_dir, den_dir, size self.names = [n[:-4] for n in os.listdir(den_dir) if n.endswith(".npy")] def __len__(self): return len(self.names) def __getitem__(self, idx): import cv2 name = self.names[idx] img = cv2.imread(os.path.join(self.img_dir, name + ".jpg")) img = cv2.resize(img, (self.size, self.size)) img = img[:, :, ::-1].astype(np.float32) / 255.0 img = torch.from_numpy(img).permute(2, 0, 1) den = np.load(os.path.join(self.den_dir, name + ".npy")) den = cv2.resize(den, (self.size // 8, self.size // 8), interpolation=cv2.INTER_AREA) den = den / (den.sum() + 1e-6) * np.load(os.path.join(self.den_dir, name + ".npy")).sum() return img, torch.from_numpy(den).unsqueeze(0).float() def train_one_epoch(model, loader, optimizer, device): model.train() total_loss = 0 for img, den in loader: img, den = img.to(device), den.to(device) pred = model(img) loss = torch.mean((pred - den) ** 2) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader)逻辑说明:DensityDataset里对密度图做了INTER_AREA下采样,然后按原始总人数重新归一化——这一步是保证下采样后人数不丢的关键,直接 resize 会让总和漂移。训练循环里只写了 MSE,实际要加局部计数损失的话,在loss后面再拼一项即可。设备选择上,无人机场景数据量大,单卡 8G 显存跑 512 输入、batch 8 基本够用。
参数说明:size=512是输入分辨率,越大密集区域细节越多但显存吃紧;size // 8对应网络 stride 8 的输出尺寸,改输入尺寸时这里要同步。学习率 1e-4 对 Adam 偏保守,收敛慢但稳;想快可以上 3e-4,但要盯着 loss 有没有震荡。
4.2 分档 MAE 评估脚本与结果解读
@torch.no_grad() def evaluate(model, loader, device): model.eval() buckets = {"sparse": [], "medium": [], "dense": []} for img, den in loader: img = img.to(device) pred = model(img).cpu() gt_count = den.sum().item() pred_count = pred.clamp(min=0).sum().item() err = abs(pred_count - gt_count) if gt_count < 50: buckets["sparse"].append(err) elif gt_count < 200: buckets["medium"].append(err) else: buckets["dense"].append(err) for k, v in buckets.items(): if v: print(f"{k}: MAE={np.mean(v):.2f}, n={len(v)}")逻辑说明:pred.clamp(min=0)是因为网络输出可能有小负值,负密度没有物理意义,求和前必须截断。分档阈值 50 和 200 是按 VisDrone 的人数分布定的,换数据集要重新看分布再定。如果 dense 档 MAE 明显高于 sparse 档两三倍,说明模型对密集区域欠拟合,优先加局部计数损失或增大输入分辨率,而不是盲目加训练轮数。
4.3 数据增强里哪些能用哪些会帮倒忙
水平翻转、随机裁剪、亮度对比度扰动都可以用,对密度图做对应变换即可。但要注意:随机裁剪时如果裁掉了大量人头,密度图求和会骤降,模型会学到「图里人少」的虚假关联。正确做法是裁剪后重新统计框中心是否落在裁剪区域内,只保留中心点在区域内的密度贡献,或者干脆用整图缩放代替裁剪。旋转增强在航拍场景要慎用,因为无人机视角有固定的俯仰关系,大角度旋转会破坏这种先验,小角度(正负 10 度内)可以试。
5. 避坑与排查:五个让 MAE 突然翻倍的真实原因
5.1 密度图求和和标注人数对不上
现象:训练 loss 能降,但验证集预测人数系统性偏低 10% 到 20%。原因:生成密度图时高斯滤波后没有重新归一化,或者下采样用了平均池化导致总能量损失。解决:在生成脚本和 Dataset 里都加一道断言,abs(density.sum() - len(boxes)) < 1e-3,不满足就打印文件名排查。这个断言我建议一直留着,成本极低。
5.2 输入尺寸不是 stride 的整数倍
现象:训练时报 shape mismatch,或者 loss 计算时广播出诡异结果。原因:网络 stride 8,输入宽高不能被 8 整除时,输出特征图比标签少一两个像素。解决:Dataset 里 resize 到 8 的倍数,或者在网络最后加一次插值对齐到标签尺寸。后者更稳,但插值方式要用双线性且之后重新归一化。
5.3 验证集指标虚高但上线就崩
现象:验证集 MAE 很低,换一批真实航拍图预测人数差得离谱。原因:训练和验证来自同一批场景序列,模型记住了背景纹理而不是人。解决:按场景划分而不是按图片随机划分,VisDrone 的序列编号字段可以用来做分组划分,同一序列的图只能出现在一个集合里。
5.4 学习率过大导致密度图全零
现象:训练几个 epoch 后预测密度图全为 0,MAE 等于平均人数。原因:MSE 损失下,模型发现输出全零的 loss 比乱预测低,陷入退化解。解决:降低初始学习率到 1e-4 以下,或者在损失里加一项对预测总和的约束,逼模型至少把人数估对。这个坑在密集图上尤其常见,血泪经验。
5.5 显存溢出被误判为模型太大
现象:batch size 调到 4 还 OOM。原因:密度图标签是全分辨率 float32,一张 1024x1024 就是 4MB,加上中间特征图,显存占用远超预期。解决:标签在 Dataset 里就下采样到网络输出分辨率再返回,不要在全分辨率上做 loss;另外用torch.cuda.amp混合精度,显存能省近一半。
6. 进阶技巧:用切片推理把大图 MAE 再压一档
无人机原图动辄 2000 像素以上,直接缩放到 512 训练和推理,密集小目标会丢细节。一个成本很低但效果明显的技巧是切片推理:把大图切成有重叠的 512 块,逐块预测密度图,再按重叠区域加权拼回全图,最后求和得人数。重叠比例设 0.25 到 0.5,重叠区用高斯权重融合,避免拼接缝处密度断裂。
def sliding_inference(model, img, patch=512, overlap=0.25, device="cuda"): stride = int(patch * (1 - overlap)) h, w = img.shape[:2] density = np.zeros((h // 8, w // 8), dtype=np.float32) weight = np.zeros_like(density) for y in range(0, h, stride): for x in range(0, w, stride): y2, x2 = min(y + patch, h), min(x + patch, w) y1, x1 = max(0, y2 - patch), max(0, x2 - patch) crop = img[y1:y2, x1:x2] crop_t = torch.from_numpy(crop).permute(2, 0, 1).float().unsqueeze(0).to(device) / 255.0 with torch.no_grad(): pred = model(crop_t).clamp(min=0).cpu().numpy()[0, 0] ph, pw = pred.shape density[y1 // 8:y1 // 8 + ph, x1 // 8:x1 // 8 + pw] += pred weight[y1 // 8:y1 // 8 + ph, x1 // 8:x1 // 8 + pw] += 1.0 density = density / np.maximum(weight, 1e-6) return density.sum()逻辑说明:切片坐标按 stride 滑动,边界处用max(0, ...)回拉保证每块都是 patch 大小,避免边缘块尺寸不足导致网络报错。融合用简单平均权重,重叠区被多次预测后取均值,比直接覆盖稳。参数上overlap=0.25是速度和精度的折中,密集场景可以提到 0.5,代价是推理时间翻倍。
验证这套切片推理有没有用,方法很直接:拿验证集里人数最多的 20 张图,分别用整图缩放推理和切片推理算 MAE,如果切片版在密集档低 15% 以上就值得上。我自己的习惯是每次改完推理流程,先跑这 20 张的对比,再决定要不要全量重跑,省时间也省心。这套流程从数据转换到切片推理走下来,一个中等规模的航拍人群计数任务基本能在一到两周内跑出可交付的基线,剩下的就是按分档指标反复磨密集场景。希望帮到你。
本文还有配套的精品资源,点击获取