地铁客流实时监测中的密度估计与深度学习实践
2026/9/18 4:41:06 网站建设 项目流程

简介:这是一份关于深度学习技术用于地铁客流实时监测的学术论文,适合轨道交通运营、智能交通系统建设以及计算机视觉方向的研究人员参考。论文从传统客流统计方法的痛点切入,指出人工统计主观性强、红外感应易漏数、三辊闸影响通行效率等问题,进而提出将目标检测算法引入视频监控的思路。文章采用单阶段目标检测网络作为核心,使用轻量化特征提取主干提升推理速度,并在检测后叠加目标跟踪机制,以降低重复计算开销与系统功耗。实验部分给出了基于开源深度学习框架的实施细节,利用地铁站口真实监控视频构建训练数据集,模型平均检测精度达到百分之八十七点九,验证了方案在实际场景中的可行性。资源包包含一个完整的PDF格式电子文档,内容涵盖方法原理、网络结构、公式推导、实验结果及参考文献,总大小约为一点五六兆字节,便于离线阅读和引用。该资源目前已有约一百六十人学习,对客流统计、目标检测及相关课题研究具有参考价值。

1. 晚高峰站台的人数是算出来的,不是数出来的

晚高峰的站台上,人工数人头这件事基本做不成。三个人在屏蔽门前挤成一团,监控画面里能看清脸都算运气,更不要说把“当前站台滞留人数”变成一个实时更新的数字。红外对射只能统计通过闸机的人数,对“人堆在屏蔽门前面挪不动”这种状态完全无感;视频轮巡靠保安盯着屏幕,注意力持续不了几分钟。基于深度学习的地铁客流实时监测,做的事情就是把摄像头画面直接喂给神经网络,用模型输出实时的人群密度或滞留人数,再把这个数字接进调度和限流系统。适合的人:做地铁信息化系统集成的工程师、负责智慧车站算法落地的开发、以及想知道这类从论文到产线之间有多少坑的研究生。真正难的不是训练一个准的模型,而是让它在拉流、抽帧、推理、平滑这一整条链路上稳定地跑起来。

2. 地铁客流深度学习的场景定义与密度估计选型

2.1 为什么目标检测不是第一选择

做这个标题下的方案时,第一件事不是开训练脚本,而是先想清楚摄像头装在哪、朝哪个方向看。地铁站台的摄像头大多是高空俯视或大角度斜视,一节车厢门前的区域里,人能挤到互相遮挡只露出半个肩膀。这个视角下,通用目标检测的缺陷立刻暴露:小目标漏检、密集人群的检测框互相吞并、非极大值抑制把并列的框消掉一半。即便用YOLOv8,在高密度俯视图上也很难稳定输出一个可信的人数。

所以常见的做法是换一条技术路线:不做“人框”,做“人群密度图”。模型输出一张和原图尺寸成比例的热力图,每个像素的数值代表该位置的人头密度,全图积分就得到人数估计。这条路线绕开了“每个人必须被框住”这个强约束,对遮挡、小目标、人群聚集的鲁棒性明显好得多。深度学习CNN在这里的角色是特征抽取器,负责把“哪里有人头、哪里没有”编码成空间分布,而不是负责数数。

2.2 点标注生成密度图的训练循环

密度图方案的数据标注也比检测框便宜。标注员只需要在每个人头中心点一个点,训练前用高斯核把点扩散成一个分布。一个人头如果出现在第 i 个像素,就按固定方差的高斯核往周围散布能量,最后整张图的积分刚好等于人头总数。这个预处理逻辑是密度估计的基石,直接在数据加载器里做,不占用额外的标注成本。

import cv2 import numpy as np import torch from torch.utils.data import Dataset class CrowdDataset(Dataset): def __init__(self, img_paths, dot_maps, sigma=4.0): self.img_paths = img_paths self.dot_maps = dot_maps # 每张图对应一个点标注矩阵 self.sigma = sigma def __getitem__(self, idx): img = cv2.imread(self.img_paths[idx]) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (512, 512)) / 255.0 h, w = img.shape[:2] dots = self.dot_maps[idx] # 把点坐标缩放到与resize后相同的空间 dots = cv2.resize(dots, (w, h), interpolation=cv2.INTER_NEAREST) density = self._points_to_density(dots, h, w) img_t = torch.from_numpy(img).permute(2, 0, 1).float() den_t = torch.from_numpy(density).unsqueeze(0).float() return img_t, den_t def _points_to_density(self, dots, h, w): pts = np.argwhere(dots > 0) density = np.zeros((h, w), dtype=np.float32) kh = int(3 * self.sigma) kernel = np.zeros((2 * kh + 1, 2 * kh + 1), dtype=np.float32) for y, x in pts: cv2.circle(kernel, (kh, kh), int(self.sigma), 1, -1) kernel = cv2.GaussianBlur(kernel, (0, 0), self.sigma) x0, x1 = max(0, x - kh), min(w, x + kh + 1) y0, y1 = max(0, y - kh), min(h, y + kh + 1) density[y0:y1, x0:x1] += kernel[:y1 - y0, :x1 - x0] return density

逻辑说明:先把点标注矩阵缩放到和输入图一致的分辨率,再枚举每个点坐标,用固定方差的高斯核做局部扩散。sigma=4.0适合人头直径约 8~12 像素的站台俯视场景;如果摄像头的安装高度让头变得更小,要同步下调。

参数说明:dot_maps是稀疏矩阵,只有人头中心位置为 1。cv2.GaussianBlur的核大小不显式传,交给sigma自动计算,避免不同分辨率下核尺寸不匹配。这个预处理会直接影响训练时的收敛质量和最终积分误差,属于整个深度学习模型里最需要较真的环节之一。

训练时建议用回归形式的损失,而不是分类交叉熵。很多初学者会把密度图当作普通图像分割来做,但密度图的每个像素值是连续量,分类损失不合适。常规做法是 MSE 加上 SSIM 损失,前者收紧全局积分误差,后者约束局部结构,防止模型输出一团糊。训练周期控制在 80~120 个深度学习 epoch,初始学习率 1e-4,用 RMSProp 或 Adam 都可以,weight decay 开 5e-4。

2.3 选型对比与模型容量边界

方案输出形式站台俯视场景表现部署成本结论
目标检测 YOLOv8检测框遮挡严重时漏检率上升明显不推荐做主模型
密度回归 CSRNet密度图精度高,但模型偏重适合离线分析
轻量编码器 + 密度头密度图精度略降,实时性好推荐做在线服务

密度图的实时性瓶颈不在理论而在工程。CSRNet 的原版基于 VGG16,前向一次在 512×512 输入上跑 30 毫秒以上,单路摄像头勉强能接受,但站台一期往往就是 8~10 路相机,单卡算力会被迅速吃满。我一般会把骨干网络换成轻量结构,比如基于 MobileNetV3 或 EfficientNet-Lite 的编码器,密度头只保留两层反卷积,精度下降 5% 以内,但推理耗时能砍掉一半以上。这个取舍在深度学习模型上线时几乎总是值得的。

3. 实时监测链路:用轻量 CNN 把密度图变成在线服务

3.1 拉流、抽帧与推理节奏怎么定

离线实验跑得再好,接不进实时链路就是白做。地铁客流实时监测的推理服务,处理的不是单张图片,而是一个持续的 RTSP 视频流。常见做法不是每帧都推理,而是按需抽帧:对站台这种变化不剧烈的场景,每秒抽 2~3 帧足够支撑实时性,同时对 GPU 的压力也小一个数量级。

抽帧逻辑要放在一个独立的拉流进程里,不要和推理混在一起。先启动一个常驻线程用 OpenCV 拉 RTSP 流,把最近一帧缓存到共享内存或环形队列;推理服务按自己的节奏取帧,取不到就跳过这一轮。这样即使网络出现抖动,拉流端的缓冲区也能兜住几秒钟的波动,不会直接导致推理断喂。

# 拉流进程最小骨架 ffmpeg -i rtmp://10.10.1.20/live/platform -f rawvideo -pix_fmt bgr24 -s 960x540 -r 3 pipe:1

用 ffmpeg 做拉流的好处是解码性能稳定,RTSP 重连机制不用自己写。-r 3把输出帧率限制为 3 FPS,-s 960x540把分辨率压到推理服务可接受的范围,这张图经过缩放后直接进入模型。管道的另一端是 Python 读取二进制帧,再 reshape 成 numpy 数组,这一步的耗时可以控制在几毫秒以内。

3.2 在线推理服务的代码骨架

推理服务我用 FastAPI 暴露 HTTP 接口,内部依赖 onnxruntime 做模型推理。PyTorch 训练完的模型先导出成 ONNX,避免在服务进程里背一套完整的训练框架,显存开销和依赖体积都能降下来。接口设计成接收图像数组,而不是用 multipart 传文件,减少一次编解码开销。

import numpy as np import onnxruntime as ort from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() # 创建推理会话,启用CUDA执行提供程序 sess = ort.InferenceSession("crowd_net.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) input_name = sess.get_inputs()[0].name class FrameIn(BaseModel): image: list # 形状为 [H, W, 3] 的 BGR 数组,转成 Python list 传输 scale: float = 1.0 # 原始分辨率到模型输入分辨率的缩放系数 class DensityOut(BaseModel): count: float peak: float # 密度图最大值,用于辅助判断拥挤程度 @app.post("/density", response_model=DensityOut) def predict(frame: FrameIn): img = np.asarray(frame.image, dtype=np.float32) / 255.0 blob = np.transpose(img, (2, 0, 1))[None, ...] outputs = sess.run(None, {input_name: blob})[0] density = outputs[0, 0] # 密度图积分得到人数,除以缩放系数修正回原始尺度 count = float(density.sum() / (frame.scale ** 2)) return DensityOut(count=round(count, 1), peak=float(density.max()))

逻辑说明:scale这个参数容易被忽略。模型训练时的输入是缩放过的小图,密度图积分得到的是小图尺度上的人数,必须除以缩放系数的平方才能还原成原始画面里的人数。这是个很典型的坑,许多人上线几天后发现人数比实际偏小,症结往往在这里。

参数说明:ONNX 的providers列表里,CUDA 在前、CPU 在后,是为了让 onnxruntime 在 GPU 不可用时自动回退,避免部署机器没有独立显卡时服务直接崩溃。count保留一位小数,让上层展示和告警逻辑自己去决定要不要取整,不要在接口层丢失精度。

服务启动命令:

uvicorn metro_infer:app --host 0.0.0.0 --port 8080 --workers 1 --limit-max-requests 5000

这里的--workers 1是刻意的。GPU 推理主循环常驻显存,多个 worker 会让每份显存被重复分配,实际推不了更多路流,反而白白占用资源。如果要扩展吞吐,优先加 GPU 而不是加 worker。

3.3 环境配置与上线前的最小压测

深度学习环境配置在服务端尽量做减法。不需要装 CUDA 工具链全家桶,只需要匹配显卡驱动的 CUDA runtime、onnxruntime-gpu 和对应版本的 cuDNN。很多部署事故都出在环境版本错位上,最常见的错误是拿训练机的驱动版本去要求推理机,推理机可能是一台很老的双路 Xeon,显卡也是几年前的型号。先把推理机上的驱动支持的最高 CUDA 版本查清楚,再倒推 ONNX Runtime 的版本,这个顺序不能反。

压测用 locust 或者简单的并发脚本都能做。第一手要看的不是单次耗时,而是 P99 延迟和 GPU 利用率。站台客流监测不是高频交易,P99 延迟在 500 毫秒以内就完全够用;GPU 利用率如果长期低于 30%,说明瓶颈在拉流或网络传输,不在推理。压测时故意让单路视频的抽帧率翻倍,观察显存和耗时变化,可以判断这台机器到底还能塞几路相机。

# 压测命令方向示意 locust -f load_test.py --headless -u 20 -r 5 -t 2m --host http://127.0.0.1:8080

压测结果如果显示 20 并发时 P99 抖动明显,优先怀疑数据预处理线程和推理线程之间的队列锁。FastAPI 的同步接口在内部走线程池,一旦predict里的 numpy 转置操作和 onnxruntime 的 GIL 释放配合不好,线程切换开销会吃掉大部分余量。把数据预处理挪到请求进入接口之前完成,或者改用异步执行器包装推理,通常能缓解。

4. 时序平滑与告警阈值:让单帧输出变成稳定的客流事件

4.1 单帧预测抖动的来源

单帧密度图的积分值并不稳定。同一时刻、同一站位,模型输出的 count 波动幅度可能有正负 10%。原因不复杂:抽帧命中的瞬间,列车正好进站,一拨人从车厢涌出,画面里的人数在 2 秒内上升 40%;下一秒车门关闭,人群往扶梯方向移动,密度图又快速回落。这种抖动是真实客流节奏,不是模型噪声,不能靠简单压低灵敏度滤掉。真正需要处理的是另一类抖动:同一个静止人群,因为传感器噪声或压缩伪影,相邻两帧积分值却上下跳。

处理这类抖动,深度学习模型本身已经尽力,剩下的要交给时序层。常见做法是把单帧积分值送入一个带遗忘因子的指数平滑器,再叠一个迟滞状态机,避免告警门限附近反复横跳。注意,这一层对实时监测的意义和模型精度同等重要,不做的后果就是大屏上的数字几十秒内跳个没完。

4.2 指数平滑与状态机代码

class CrowdStateMachine: def __init__(self, alpha=0.3, warn=80, alarm=120, recover_gap=15): self.alpha = alpha self.smooth = None self.level = 0 # 0 正常, 1 关注, 2 告警 self.cross_time = 0 # 进入当前状态的时间戳 self.recover_gap = recover_gap # 降级需要等待的秒数 def update(self, raw_count, ts): if self.smooth is None: self.smooth = raw_count else: self.smooth = (1 - self.alpha) * self.smooth + self.alpha * raw_count # 进入高等级状态立即生效;降级则等待稳定期 if self.smooth >= alarm: if self.level < 2: self.level = 2 self.cross_time = ts elif self.smooth >= warn: if self.level < 1: self.level = 1 self.cross_time = ts else: if self.level > 0 and ts - self.cross_time > self.recover_gap: self.level = 0 self.raw = raw_count return self.level, self.smooth

逻辑说明:状态机里“升级立即生效、降级等待稳定期”的原则,是从地铁运营方的告警习惯里总结出来的。人数超过阈值时必须马上通知,告知延误几秒钟都可能影响调度;而人数降回正常区间后,大屏跳回绿色不需要那么快,多等十几秒反而让值班员看得更稳。

参数说明:alpha=0.3是对 3 FPS 抽帧频率调过的值,平滑窗口约 3~4 秒。更大的 alpha 让曲线更跟手,但会放大瞬时抖动;更小的 alpha 让数字更稳,但会把真正的客流突增钝化掉。调参顺序是先固定 alpha,再调 warn 和 alarm,最后才碰recover_gap,不要同时动三个参数。

4.3 调参顺序与各参数含义

参数推荐初值调参方向影响
alpha0.3曲线跳动多就调小,反应慢就调大平滑强度
warn80结合历史最高客流的 70%进入关注状态的门槛
alarm120结合站台设计容量和限流要求触发告警的门槛
recover_gap15 秒从告警降级到正常的观察窗口防止门限邻域抖动

阈值设定不能拍脑袋。如果车站有 AFC 闸机历史数据,先拉出过去 30 天晚高峰的站台密度分布,把 warn 设置在 85 分位、alarm 设置在 95 分位附近,再用实际突发场景回放验证。没有历史数据的话,拿一周的模型输出做离线统计也能近似,不要一上来就定死数值。调参过程要留痕,每改一次参数就保存一份输出序列和状态翻转记录,否则无法判断是模型变好了还是参数变松了。

这层时序逻辑还有第二个作用:为告警系统提供“持续时长”。客流监测的价值不完全在于此刻有多少人,而在于“超过承载阈值持续了多久”。一次 10 秒的越线可能是列车到达的正常波动,持续 3 分钟的越线就一定要触发限流预案。把cross_time和当前时间戳的差值传给上层,让告警规则引擎去决定何时真正发通知,比在算法服务里硬编码更合理。

5. 用频谱分析验证模型输出与列车到达节奏的对齐

模型上线后,第一步不是信 loss 曲线,而是做一个很简单的对齐验证:客流时间序列里,应该能看到与列车到达间隔一致的周期性。地铁平峰期列车可能 5 分钟一班,高峰期 2 分钟一班,这个节奏一定会反映在站台人数曲线里。如果模型输出序列的频谱里找不到对应的峰值,说明模型要么对人群不敏感,要么抽帧节奏有问题。

import numpy as np from scipy.fft import rfft, rfftfreq counts = np.load("station_platform_counts.npy") # 1Hz采样, 连续两小时 fs = 1.0 n = len(counts) spectrum = np.abs(rfft(counts - counts.mean())) freqs = rfftfreq(n, d=1.0 / fs) # 打印0.5~5分钟周期范围内的主要频率成分 for f, amp in sorted(zip(freqs, spectrum), key=lambda x: -x[1])[:8]: if 1 / 180 <= f <= 1 / 120: print(f"period={1 / f:.0f}s amplitude={amp:.1f}")

逻辑说明:counts - counts.mean()先去掉直流分量,避免零频把其他频率压得看不见。rfft是实信号的半边频谱,对客流这种实数值序列足够。筛选1/1801/120的频率范围,对应的是 2~3 分钟周期,这正好能覆盖高峰期间隔的列车到达节奏。

如果这段代码跑不出明显的峰值,先看一眼数据采样的均匀性。抽帧进程只要被拉流重连阻塞过几秒,时间戳间隔就开始不均匀,频谱里会出现一批假峰。修正方式是记录每帧真实到达时间戳,重采样成均匀序列后再做频谱分析。另外注意把列车时刻表也导入进来做对照,客流峰值应该比列车到达时刻滞后 10~20 秒,这个滞后对应的是下车乘客从车厢走到摄像头视野的时间。对比时允许存在这个偏移,不必强求完全同步。

验证通过之后,可以把频谱分析做成一个定期巡检脚本:每天晚上把当天的客流序列跑一遍,输出过去 24 小时的周期稳定性。周期突然消失,往往意味着上游相机离线或者抽帧程序异常退出,模型本身没坏,但数据链路已经断了。这个技巧成本极低,却能第一时间暴露数据链路的隐性故障,比盯着 GPU 利用率曲线可靠得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询