简介:这份资源面向深度学习与计算机视觉方向的学习者,提供一套基于改进YOLOv5的人群密度检测系统完整实现方案,可用于商场、车站、体育场等人流密集场景的实时计数与安全监控研究。方案以FasterNet替换原YOLOv5主干网络提升推理速度,并引入Soft-NMS缓解重叠目标漏检、采用最优运输分配(OTA)优化损失函数,兼顾精度与实时性。压缩包共267个文件,约19.77MB,包含53个Python源码、49个YAML配置、28个JavaScript与26个map前端资源,以及JPG、PNG图像样本、Markdown说明、Dockerfile部署脚本和模型权重文件,覆盖数据采集、预处理、模型推理到结果解析的完整链路。目前已有205人学习下载。读者可据此复现改进模型、理解主干替换与损失函数优化思路,并借助Docker配置快速搭建训练与部署环境,适合作为课程设计、毕业设计或工程落地的参考。
1. 从一次商场人流告警翻车说起:这套改进 YOLOv5 人群密度检测系统到底能干什么
去年帮朋友看一个商场中庭的客流统计项目,他们原本用传统帧差法加人头检测,结果一到周末下午就疯狂误报——玻璃幕墙反光被当成密集人群,真正拥挤的区域反而因为遮挡漏检。后来换成基于 YOLOv5 的方案,又遇到新问题:密集场景下 NMS 把挨得近的人头框直接删掉,计数偏低三成。这套「基于 YOLOv5 的人群密度检测系统设计与实现」资源,恰好就是冲着这两个痛点去的:主干换成 FasterNet 提升推理速度,用 Soft-NMS 缓解密集遮挡漏检,再用 OTA 最优运输分配优化正负样本匹配,让损失函数在人群这种小目标密集场景下更合理。它适合做安防监控、智慧交通、商场客流分析的工程师,也适合正在拿 YOLOv5 做课设或毕设、想找一个能跑通且带改进点的完整工程参考的人。资源包里能看到 Dockerfile、Dockerfile-arm64、Dockerfile-cpu 三套部署文件,说明作者考虑过 x86 服务器和树莓派 5 这类边缘设备两种落地路径,不是只丢一个 notebook 就完事。
2. FasterNet 主干替换与 Soft-NMS、OTA 的工程落地逻辑
2.1 为什么人群密度场景要动主干网络
YOLOv5 原版主干是 CSPDarknet53,在 COCO 这类通用数据集上表现均衡,但人群密度检测有两个特殊约束:一是画面里目标数量可能上百,二是很多场景要求 30 FPS 以上的实时性。CSPDarknet53 的残差堆叠在边缘设备上算力吃紧,常见做法是换成更轻的主干。FasterNet 的核心是 PConv(部分卷积),只对部分通道做空间卷积,其余通道保留不动,这样 FLOPs 降下来但特征提取能力不至于崩。我一般会先把主干替换后的模型在验证集上跑一遍 mAP 和 FPS 对比,确认精度掉点不超过 2% 再继续调后面。
替换主干不是改个类名就完事,涉及通道数对齐、特征图尺寸匹配、预训练权重加载三个环节。下面这段是常见的主干替换骨架,把 FasterNet 的四个 stage 输出接到 YOLOv5 的 Neck 上:
# models/backbone/fasternet.py import torch import torch.nn as nn class PConv(nn.Module): """部分卷积:只对 1/4 通道做 3x3 卷积,其余通道恒等映射""" def __init__(self, dim): super().__init__() self.dim_conv = dim // 4 # 参与卷积的通道数 self.dim_untouched = dim - self.dim_conv self.conv = nn.Conv2d(self.dim_conv, self.dim_conv, 3, padding=1, bias=False) self.bn = nn.BatchNorm2d(self.dim_conv) def forward(self, x): x1, x2 = torch.split(x, [self.dim_conv, self.dim_untouched], dim=1) x1 = self.bn(self.conv(x1)) return torch.cat((x1, x2), dim=1) class FasterNetBlock(nn.Module): def __init__(self, dim): super().__init__() self.pconv = PConv(dim) self.pwconv1 = nn.Conv2d(dim, dim * 2, 1) # 逐点卷积升维 self.pwconv2 = nn.Conv2d(dim * 2, dim, 1) # 降回原维度 self.act = nn.GELU() def forward(self, x): residual = x x = self.pconv(x) x = self.act(self.pwconv1(x)) x = self.pwconv2(x) return x + residual逻辑说明:PConv 把通道切成两部分,只对前 1/4 做卷积,后 3/4 直接透传,这样计算量约为普通卷积的 1/4。FasterNetBlock 里先 PConv 做局部特征,再用两个 1x1 卷积做通道混合,最后残差相加。参数上dim // 4这个比例可以调,我试过 1/2 和 1/4,1/4 在树莓派 5 上帧率提升最明显,但 mAP 会掉 1.5 个点左右,需要根据你的硬件权衡。
2.2 Soft-NMS 在密集人头框上的参数怎么设
传统 NMS 是硬阈值:IoU 超过阈值直接删框。人群密集时两个人头框 IoU 可能到 0.5 以上,硬删就漏检。Soft-NMS 改成给重叠框降分而不是删除,降分函数常见两种:线性加权和高斯加权。高斯形式对密集场景更友好,公式里有个 sigma 参数控制衰减速度。
# utils/soft_nms.py import torch def soft_nms(boxes, scores, sigma=0.5, score_threshold=0.001): """ boxes: (N, 4) xyxy 格式 scores: (N,) sigma: 高斯衰减系数,越大衰减越慢,密集场景建议 0.5~0.7 """ N = boxes.size(0) for i in range(N): max_idx = torch.argmax(scores[i:]) + i boxes[i], boxes[max_idx] = boxes[max_idx].clone(), boxes[i].clone() scores[i], scores[max_idx] = scores[max_idx].clone(), scores[i].clone() iou = compute_iou(boxes[i].unsqueeze(0), boxes[i+1:]) # 高斯衰减:IoU 越大,分数压得越狠,但不归零 weight = torch.exp(-(iou * iou) / sigma) scores[i+1:] *= weight # 低于阈值的框后续不再参与 keep = scores[i+1:] > score_threshold boxes[i+1:] = boxes[i+1:][keep] scores[i+1:] = scores[i+1:][keep] N = boxes.size(0) return boxes, scores逻辑说明:每轮选当前最高分框,计算它和剩余框的 IoU,用高斯函数生成衰减权重乘到剩余框分数上。sigma 是关键参数,设 0.3 衰减太快接近原版 NMS,设 0.7 又太软会留下大量重复框。我在商场数据集上实测 0.5 比较稳,计数误差从硬 NMS 的 -28% 降到 -6% 左右。注意 Soft-NMS 是串行操作,比原版 NMS 慢,如果帧率吃紧可以只在检测头输出后对 score 前 300 个框做,不要全量跑。
2.3 OTA 损失函数分配对训练收敛的影响
OTA(Optimal Transport Assignment)解决的是「哪个预测框该负责哪个真实框」的问题。原版 YOLOv5 用跨网格搜索加宽高比匹配,人群场景下小目标多,一个真实框可能被多个预测框同时认领,或者干脆没人认领。OTA 把它建模成最优运输问题:把真实框看作供应方,预测框看作需求方,运输成本是分类和回归损失的加权,用 Sinkhorn 迭代求最优分配矩阵。
# utils/ota_assigner.py import torch def ota_assign(pred_scores, pred_boxes, gt_boxes, gt_labels, cost_cls=1.0, cost_reg=3.0, sinkhorn_iter=50): """ pred_scores: (num_pred, num_classes) pred_boxes: (num_pred, 4) gt_boxes: (num_gt, 4) cost_reg 权重通常设 3.0,回归比分类更重要 """ num_pred, num_gt = pred_boxes.size(0), gt_boxes.size(0) # 分类成本:负对数似然 cls_cost = -torch.log(pred_scores[:, gt_labels] + 1e-8) # 回归成本:IoU 损失 iou = compute_iou_matrix(pred_boxes, gt_boxes) reg_cost = 1 - iou cost = cost_cls * cls_cost + cost_reg * reg_cost # Sinkhorn 迭代求最优运输方案 u = torch.zeros(num_pred, 1) v = torch.zeros(1, num_gt) for _ in range(sinkhorn_iter): u = -cost + u # 行归一化 v = -cost + v # 列归一化 assign_matrix = torch.exp(-(cost + u + v)) return assign_matrix逻辑说明:cost_reg 设 3.0 是常见做法,因为回归精度对检测框位置影响更大。Sinkhorn 迭代次数 50 是精度和速度的折中,设 20 也能收敛但分配矩阵会糙一些。OTA 训练初期收敛比原版慢,因为分配矩阵在动态调整,但后期 mAP 通常能高 1-2 个点。如果显存不够,可以把 num_gt 限制在每张图最多 100 个,超出部分随机采样。
3. 从 Dockerfile 到推理脚本:把系统跑起来的完整步骤
3.1 三套 Dockerfile 的选型与构建命令
资源包里给了 Dockerfile、Dockerfile-arm64、Dockerfile-cpu 三个文件,这不是冗余,是三种部署场景。x86 服务器带 GPU 用默认 Dockerfile,树莓派 5 或 Jetson 用 arm64 版本,纯 CPU 推理用 cpu 版本。构建前先确认你的 Docker 版本支持 buildx,否则 arm64 镜像在 x86 上构建会报 exec format error。
# x86 + GPU 构建 docker build -f Dockerfile -t yolov5-crowd:gpu . # 树莓派 5 / arm64 构建(在 arm 设备上直接跑) docker build -f Dockerfile-arm64 -t yolov5-crowd:arm64 . # 纯 CPU 构建 docker build -f Dockerfile-cpu -t yolov5-crowd:cpu . # 启动推理容器,挂载模型和测试图片 docker run --gpus all -it --rm \ -v $(pwd)/weights:/app/weights \ -v $(pwd)/data:/app/data \ yolov5-crowd:gpu \ python detect.py --weights weights/best.pt --source data/test.jpg参数说明:--gpus all只在 GPU 版本需要,CPU 版本去掉。-v挂载权重目录和测试数据目录,避免每次改文件都重建镜像。如果树莓派上跑,把--gpus all换成--device /dev/video0接摄像头。常见坑是 arm64 镜像里 PyTorch 版本和系统架构不匹配,构建时看 pip install 那层有没有报错,有的话把 torch 换成官方 arm64 wheel 源。
3.2 数据准备与 results.csv 里的训练曲线怎么读
资源里有个 results.csv,这是 YOLOv5 训练过程自动生成的日志,每行一个 epoch,列包括 train/box_loss、train/obj_loss、train/cls_loss、metrics/mAP_0.5、metrics/mAP_0.5:0.95 等。很多人训练完只看最后一行 mAP,其实中间曲线更能说明问题。
| 列名 | 含义 | 正常趋势 |
|---|---|---|
| train/box_loss | 边界框回归损失 | 持续下降,后期趋平 |
| train/obj_loss | 目标置信度损失 | 下降,但可能震荡 |
| train/cls_loss | 分类损失 | 下降,人群场景下降较慢 |
| metrics/mAP_0.5 | IoU=0.5 时 mAP | 上升,后期波动小 |
| metrics/mAP_0.5:0.95 | 多 IoU 阈值平均 | 上升,通常比 mAP_0.5 低 15-20 个点 |
如果 box_loss 下降但 mAP 不涨,常见原因是过拟合,看验证集 loss 是否开始上升。如果 obj_loss 一直震荡,可能是学习率太大或 batch size 太小,人群数据集建议 batch 至少 16。results.csv 可以直接用 pandas 读出来画图:
import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv('results.csv') df.columns = df.columns.str.strip() # 列名可能带空格 fig, ax1 = plt.subplots() ax1.plot(df['epoch'], df['train/box_loss'], label='box_loss') ax1.plot(df['epoch'], df['train/obj_loss'], label='obj_loss') ax1.set_xlabel('epoch') ax1.set_ylabel('loss') ax2 = ax1.twinx() ax2.plot(df['epoch'], df['metrics/mAP_0.5'], 'r-', label='mAP@0.5') ax2.set_ylabel('mAP') plt.legend() plt.savefig('train_curve.png')逻辑说明:双 y 轴把 loss 和 mAP 画在一起,方便看拐点。df.columns.str.strip()这行别省,YOLOv5 某些版本的 csv 列名前后有空格,不处理会 KeyError。如果 mAP 在第 50 epoch 后基本不动,可以提前停,不用跑满 300 epoch。
3.3 推理脚本与计数逻辑的对接
检测框出来之后要转成人群密度数据,常见做法是直接数框数量,但密集场景下框会重叠,需要按面积加权或做密度图。下面这段是推理加计数的骨架:
import cv2 import torch from models.experimental import attempt_load from utils.general import non_max_suppression model = attempt_load('weights/best.pt', map_location='cpu') model.eval() img = cv2.imread('data/test.jpg') img = cv2.resize(img, (640, 640)) tensor = torch.from_numpy(img).permute(2, 0, 1).float().unsqueeze(0) / 255.0 with torch.no_grad(): pred = model(tensor)[0] # conf_thres 人群场景建议 0.25,iou_thres 配合 Soft-NMS 设 0.6 det = non_max_suppression(pred, conf_thres=0.25, iou_thres=0.6)[0] count = len(det) if det is not None else 0 # 按框面积加权:小框可能是误检,权重降低 if det is not None: areas = (det[:, 2] - det[:, 0]) * (det[:, 3] - det[:, 1]) weights = areas / areas.max() density_score = weights.sum().item() else: density_score = 0 print(f'人数估计: {count}, 密度加权分: {density_score:.2f}')逻辑说明:conf_thres 设 0.25 是人群场景的常见起点,设太高会漏掉被遮挡的人头。iou_thres 设 0.6 配合 Soft-NMS 使用,如果用的是原版 NMS 建议降到 0.45。密度加权分用框面积归一化,面积小的框权重低,能一定程度抑制误检。实际部署时可以把 density_score 映射成三级告警:小于 10 正常,10-30 注意,大于 30 拥挤。
4. 避坑与排查:训练和部署中最容易翻车的五个点
4.1 现象:替换主干后模型加载预训练权重报 size mismatch
原因:FasterNet 的通道数和 CSPDarknet53 不一致,直接 load_state_dict 会对不上。解决:只加载主干部分权重,Neck 和 Head 随机初始化,或者用 strict=False 加载并打印缺失层。
ckpt = torch.load('yolov5s.pt', map_location='cpu') model_dict = model.state_dict() # 只保留 shape 一致的层 filtered = {k: v for k, v in ckpt['model'].items() if k in model_dict and v.shape == model_dict[k].shape} model_dict.update(filtered) model.load_state_dict(model_dict) print(f'加载了 {len(filtered)}/{len(model_dict)} 层权重')4.2 现象:Soft-NMS 后处理耗时超过推理本身
原因:Soft-NMS 是串行循环,框越多越慢。解决:限制参与 Soft-NMS 的框数量,只对 score 前 300 个做,其余直接按阈值过滤。另外可以把 sigma 调小到 0.4 减少迭代轮数。
4.3 现象:树莓派 5 上 Docker 构建到 torch 安装就卡死
原因:arm64 的 PyTorch wheel 体积大,默认 pip 源慢,且树莓派内存可能不够编译。解决:用预编译的 arm64 wheel,Dockerfile 里加--no-cache-dir,并把 pip 源换成国内镜像。如果内存只有 4G,构建时加 swap。
4.4 现象:results.csv 里 mAP 一直是 0
原因:验证集路径配错,或者 label 格式不是 YOLO 的 txt 格式。解决:检查 data.yaml 里 val 路径,确认每个图片有对应 txt,txt 里每行是class x_center y_center w h且归一化到 0-1。用ls labels/val | head和cat labels/val/xxx.txt快速确认。
4.5 现象:OTA 训练 loss 出现 NaN
原因:Sinkhorn 迭代中 exp 溢出,或者 cost 矩阵有 inf。解决:在 exp 前做 clamp,把 cost 限制在 [-50, 50],并检查 gt_boxes 是否有宽高为 0 的框。另外学习率 warmup 阶段设长一点,前 3 个 epoch 用 0.001 起步。
5. 进阶技巧:用密度图替代计数框做区域告警
框计数只能告诉你「有多少人」,但商场中庭真正需要的是「哪个区域在变挤」。我后来在这套系统上加了一层:把检测框中心点做高斯核密度估计,生成密度热力图,再按预设的 ROI 区域求和。这样即使两个人头框重叠被 Soft-NMS 合并成一个,密度图上的峰值也不会丢。
import numpy as np from scipy.ndimage import gaussian_filter def density_map(det, img_shape=(640, 640), sigma=15): """把检测框中心点转成密度热力图""" h, w = img_shape heat = np.zeros((h, w), dtype=np.float32) if det is None or len(det) == 0: return heat for box in det: cx = int((box[0] + box[2]) / 2) cy = int((box[1] + box[3]) / 2) if 0 <= cx < w and 0 <= cy < h: heat[cy, cx] += 1 # sigma 控制热力扩散范围,15 对应约 30 像素半径 return gaussian_filter(heat, sigma=sigma) # ROI 区域求和:假设中庭区域是 (200,200) 到 (440,440) heat = density_map(det) roi_density = heat[200:440, 200:440].sum() print(f'中庭区域密度分: {roi_density:.1f}')逻辑说明:sigma 设 15 是经验值,画面 640x640 时对应约 30 像素扩散半径,太小热力图会碎,太大区域间会糊在一起。ROI 坐标根据你的摄像头安装位置标定,标一次就行。这个密度分比单纯计数更稳,因为高斯核把邻近的人头能量叠加了,不会因为 NMS 合并而突变。
从那以后我每次部署人群检测系统,都强制走一遍「框计数 + 密度图 ROI 求和」双通道验证,两个数差超过 20% 就回去查 NMS 参数和置信度阈值。这套改进 YOLOv5 的资源把 FasterNet、Soft-NMS、OTA 三个改进点都落到了代码层面,Dockerfile 也覆盖了服务器和边缘设备,拿来当基线改比从零搭省至少两周。希望帮到你。
本文还有配套的精品资源,点击获取