简介:一套基于DeepSORT与OpenCV的ROI区域行人测速统计系统源码包,面向计算机视觉学习者、智能监控开发者及安防领域研究人员,用于解决特定区域内行人检测、连续跟踪与速度统计问题。包内共20个文件,包括10个Python脚本,覆盖检测器CPU/GPU版本、目标跟踪、速度估算、模型训练及数据整理等模块;9张PNG图片展示检测跟踪效果;另附README.md说明文档,便于了解项目结构与运行方式。整个压缩包仅8.72MB,轻量易部署,适合本地复现,目前已有37人学习下载。借助这套源码可完整理解视频输入、预处理、目标检测、DeepSORT深度特征匹配、基于位置变化推算速度再到统计分析的技术链路,代码模块清晰,适合作为课程设计或工程实践的参考模板。典型应用场景包括交通路口行人过街速度监测、公共场所人流疏导与安全预警,也能为城市步行环境评估提供数据参考。
1. 这个系统解决什么问题:从跟踪到测速统计的完整闭环
平时做视频分析,大家最容易高估检测和跟踪的难度,低估“把像素距离换算成真实距离”这一步的复杂度。基于Deepsort和OpenCV的ROI区域行人测速统计系统,做的就是一件很具体的事:用Deepsort把视频里的每个行人稳定跟踪住,用OpenCV在画面里划定ROI区域,再把行人穿过ROI时留下的轨迹换算成真实世界里的速度,最终输出某个时间段内的行人数、平均速度和进出方向。它对应的是安防摄像头、园区通道、商场出入口这类场景里最常被问的问题:这里一小时过了多少人、走得快还是慢、往哪个方向去。适合刚接触目标跟踪想跑通完整链路的同学,也适合要拿数据交差的工程实施人员。
2. 搭建检测跟踪链路:Deepsort和OpenCV的分工与最小主循环
2.1 Deepsort不是检测器:跟踪器与检测器的边界在哪
必须先说清楚一件事:Deepsort本身不识别行人,它只负责把检测器给出的目标框“串”成轨迹。它的核心是卡尔曼滤波预测每个轨迹的下一帧位置,再用级联匹配把新检测框和已有轨迹做关联,最后一层用IOU匹配兜底处理遮挡后重现的目标。也就是说,检测器负责每一帧的“谁在哪”,Deepsort负责跨帧的“谁是谁”。
这个分工决定了系统的错误来源也分为两类:检测器漏检导致轨迹中断,跟踪器ID switch导致速度跳变。落地时要同时防。我一般会把检测器的置信度阈值压到较低的值,比如0.3左右,宁可多输出几个假框,也要先保证不丢检。假框通常很快会被Deepsort的ReID特征层丢弃,而漏检会让一个已经算了一半速度的轨迹突然断掉,统计就不完整。
2.2 检测器选型:先保证检测质量再谈跟踪
常见做法是YOLOv5s或YOLOv8n。这两个模型的共同点是检测框稳定、小目标友好、CPU也能跑出10fps以上。实际用下来的体验是:在1080p视频里,YOLOv5s比YOLOv8n的框在行人底部更贴地,这会影响后面测速时“底部中点”这个点的投影准确性。如果摄像头安装在3米以上的高度,可以用低置信度阈值来缓解框偏高的问题。
选检测器时不要贪模型大小。就行人测速来说,检测框的稳定性比检测精度更关键。一张1080p画面里检测损失的几个点AP对测速影响不大,但底部中点上下抖动10个像素,投影到地平面后可能就是0.3米的位移误差,它会直接成为速度噪声的一部分。所以选检测器的指标建议看“不同帧之间同一人的框位置标准差”,而不是mAP。
2.3 整条数据流的最小主循环:代码与参数说明
用deep_sort_realtime这个pip包,它是社区里封装得比较顺手的Deepsort实现,和OpenCV搭配很自然。下面是一个能跑通的最小主循环,把检测、跟踪、ROI判断三个环节串起来。
import cv2 import numpy as np from deep_sort_realtime.deepsort_tracker import DeepSort # 初始化Deepsort跟踪器 tracker = DeepSort( max_age=30, max_cos_dist=0.2, nn_budget=None, embedder="mobilenet", half=True, ) cap = cv2.VideoCapture("pedestrian.mp4") roi_polygon = np.array([(200, 400), (800, 400), (950, 700), (50, 700)], np.int32) while cap.isOpened(): ret, frame = cap.read() if not ret: break # detector是占位符,实际接YOLO输出,格式为[(x1,y1,x2,y2,conf), ...] detections = detector(frame) tracks = tracker.update_tracks(detections, frame=frame) for track in tracks: if not track.is_confirmed(): continue track_id = track.track_id x1, y1, x2, y2 = track.to_ltrb() cx, cy = (x1 + x2) / 2, y2 # 底部中点,用于地面投影 if cv2.pointPolygonTest(roi_polygon, (cx, cy), False) >= 0: print(track_id, cx, cy)代码里值得注意的参数:max_age=30表示一个轨迹在连续30帧没有匹配到检测框之前不会消失,遮挡容忍度就靠它撑;max_cos_dist=0.2是ReID特征向量夹角的余弦距离阈值,大于这个值就认为不是同一个人。这两个参数是一对跷跷板——调大max_age防断档,调大max_cos_dist防窜号,但调过头会互相恶化。首次跑通时不要追求最优,用这组开局值先把流程走完,后面再针对自己的视频内容调。
2.4 OpenCV在链路里的三个不可替代职责
第一是ROI掩码。在每帧上做一次cv2.fillPoly,把多边形区域生成一张mask,再用cv2.bitwise_and过滤ROI外的检测,这个做法比在Python层做坐标判断快得多。第二是透视变换。cv2.findHomography和cv2.warpPerspective是标定和投影的标准工具,Deepsort本身完全不涉及几何变换。第三是视频读写与叠加绘制。cv2.VideoCapture读流、cv2.VideoWriter写结果视频、cv2.putText叠加速度值、cv2.polylines画轨迹,这些Deepsort包里都没有。跟踪归跟踪,图像和几何归OpenCV,这就是标题里OpenCV存在的意义。
3. ROI区域与地面标定:测速精度真正由几何决定
3.1 ROI的两种角色:测速区域和虚拟线
ROI在这个项目里承担两个角色。角色一是测速生效区域:行人的轨迹点只有落在ROI多边形内才参与速度计算。角色二是虚拟线:ROI的一条边可以充当计数线,通过判断轨迹从哪一侧穿过这条线来区分进出方向。
配置ROI最直接的方式是在视频里人工点选多边形顶点,把坐标写进配置文件。我在实际项目里通常把ROI设置成平行四边形或梯形,顺着通道走向拉长,这样轨迹在ROI内部覆盖足够多帧。ROI太小,轨迹里可用的点太少;ROI太大,把入口干扰区域也包进去了,统计会混进无关目标。
3.2 像素坐标换成米:比例尺与单应矩阵两种标定方案
把像素轨迹换算成真实距离,最简单的方案是单一比例尺:在画面里量一段已知长度的地面线段,得到“每像素等于多少米”。这个方案在俯视摄像头下还能用,一旦摄像头是斜视的——比如装在立杆上往下看——近处和远处的像素尺度完全不同,单一比例尺会让测速在近处偏大、在远处偏小,而且是系统性的。这个方案在斜视摄像头下基本是血泪经验,能不用则不用。
推荐方案是求单应矩阵H。做法是在ROI区域内的地面上取4个以上的参考点,同时记录这些点的真实世界坐标。世界坐标不一定要经纬度,只要点与点之间的相对距离是准确的就行,因为速度本身就是相对量。H把像素平面映射到地平面,之后轨迹上任何一个像素点都能投影成地面坐标,两个地面坐标之间的距离除以时间差就是速度。两种方案对比:
| 对比项 | 单一比例尺 | 单应矩阵 |
|---|---|---|
| 原理 | 全画面用同一个像素/米比 | 平面到平面射影变换 |
| 适用摄像头 | 俯视、高度差小 | 斜视立杆、常规监控 |
| 参考点要求 | 已知一段地面长度 | 4个以上像素/世界坐标点对 |
| 误差表现 | 近处偏大、远端偏小 | 均匀分布,取决于选点质量 |
| 推荐度 | 不推荐 | 推荐 |
3.3 用cv2.findHomography把轨迹投影到地平面
标定和投影的完整步骤可以直接抄。注意pixel_pts里的四个点要选择地面上的稳定参考点,不要选行人的头顶或画面边缘,因为投影最终要把人的落脚点映射到地面。
import cv2 import numpy as np # 1. 在画面里选点,顺序要和world_pts一一对应 pixel_pts = np.array([ [156, 520], # 对应世界坐标[0, 0] [524, 420], # 对应世界坐标[2, 0] [880, 520], # 对应世界坐标[2, 2] [420, 640] # 对应世界坐标[0, 2] ], dtype=np.float32) world_pts = np.array([ [0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0] ], dtype=np.float32) # 2. 求单应矩阵,RANSAC能容忍手工选点误差 H, _ = cv2.findHomography(pixel_pts, world_pts, method=cv2.RANSAC) # 3. 任意像素点 -> 地面坐标 def pixel_to_world(px, py): w = H @ np.array([px, py, 1.0]) return w[0] / w[2], w[1] / w[2] # 4. 两个地面坐标 -> 距离,单位米 def world_distance(x1, y1, x2, y2): return float(np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)) print(pixel_to_world(300, 500))这段代码有几个关键点。世界坐标用相对坐标就行,比如把ROI的某个角定为原点,向右2米、向前2米,只要真实长度准确。findHomography的RANSAC会自动排除手工选点的外点,但前提是至少给5个点,4个点只是理论上可解,实际标定时我会取6到8个点。投影完成后有个快速验证方法:随便找一帧,把一个人的脚底点投影到地面坐标,让他往前走几步,看投影轨迹是否平滑。如果投影轨迹在远端点出现弯曲,说明H不准,需要增加参考点重新算。
3.4 测速计时基准:用时间戳,别用帧号
这个坑在Deepsort项目里几乎必踩。很多第一版实现会用“轨迹的第N帧和第M帧之差除以视频fps”来算时间。视频文件是固定帧率录制时误差不大,但项目一换到实时摄像头,每帧的处理耗时是波动的,帧号差不再代表真实时间,速度值就会跟着忽大忽小。
正确做法是每处理一帧记录cap.get(cv2.CAP_PROP_POS_MSEC),或者直接用time.time()。后一种在实时流里更稳。测速公式就变成:速度等于地面位移除以真实时间差。这样不管处理器怎么波动,速度只依赖真实时间差,Deepsort的卡尔曼预测不关心时间戳一致性,但速度计算这层必须自己保证计时准。
4. 实现行人测速统计:参数配置、速度平滑与计数逻辑
4.1 Deepsort六个关键参数和一组开局配置
Deepsort能调的核心参数有六个:max_age、max_cos_dist、min_confidence、nn_budget、nms_max_overlap、embedder。min_confidence是检测框置信度阈值,低于它的检测不会进入跟踪器;nn_budget是ReID特征库容量;nms_max_overlap是检测框去重阈值;embedder决定ReID网络。
我建议的开局配置是:max_age=30、max_cos_dist=0.2、min_confidence=0.3、nn_budget=None、nms_max_overlap=0.7、embedder="mobilenet"。mobilenet在CPU上的ReID推理大约5到8毫秒,不会把整个系统拖慢。调参顺序有讲究:先固定embedder和nn_budget,然后按“检测漏检率高先降min_confidence”“轨迹频繁断就升max_age”“不同行人串号就降max_cos_dist”的顺序调,不要一上来同时动多个参数,否则出了毛病根本定位不到是谁引起的,调参直接变成玄学。
4.2 为什么不能用瞬时帧差算速度:三个典型翻车现场
用相邻两帧的坐标差直接除以帧时间,理论上就是瞬时速度,实际跑起来速度曲线毛刺非常多,甚至出现10m/s以上的荒谬值。翻车原因有三个。一是检测框噪声:底部中点在相邻帧之间抖动几个像素,投影到地面后会被放大成几十厘米的位移。二是卡尔曼滞后:Deepsort的轨迹更新有预测和修正交替,直接用预测位置和检测位置交替参与计算,会引入周期性抖动。三是底部中点本身的问题:人走在画面里,脚底点在小目标上往往不稳定,瞬时帧差把这种不稳定全部变成速度误差。所以速度计算必须做平滑。
4.3 速度平滑与统计聚合:一个可以直接改的Python模块
下面这个类把速度计算和统计聚合封装在一起,可以直接嵌入主循环。核心思想是用滑动窗口内的中位速度代替瞬时速度,同时对轨迹做一次性计数,避免重复统计。
import time import cv2 import numpy as np from collections import defaultdict class SpeedStats: def __init__(self, H, roi, window=10, max_speed=6.0): self.H = H self.roi = roi self.window = window self.max_speed = max_speed self.buffer = defaultdict(list) # track_id -> [(ts, wx, wy)] self.counted = set() # 已统计的track_id self.hourly = defaultdict(lambda: {"count": 0, "speeds": []}) def _pixel_to_world(self, px, py): w = self.H @ np.array([px, py, 1.0]) return w[0] / w[2], w[1] / w[2] def update(self, tracks): records = [] now = time.time() for tr in tracks: track_id = tr.track_id if not tr.is_confirmed() or track_id in self.counted: continue x1, y1, x2, y2 = tr.to_ltrb() wx, wy = self._pixel_to_world((x1 + x2) / 2, y2) # 底部中点投影 if cv2.pointPolygonTest(self.roi, (wx, wy), False) < 0: continue self.buffer[track_id].append((now, wx, wy)) if len(self.buffer[track_id]) > self.window: self.buffer[track_id].pop(0) if len(self.buffer[track_id]) < 5: continue buf = self.buffer[track_id] speeds = [] for i in range(1, len(buf)): dt = buf[i][0] - buf[i-1][0] if dt < 1e-6: continue dist = np.sqrt((buf[i][1] - buf[i-1][1]) ** 2 + (buf[i][2] - buf[i-1][2]) ** 2) speeds.append(dist / dt) speed = float(np.median(speeds)) if speed > self.max_speed: continue # 异常值直接丢弃 hour_key = time.strftime("%Y-%m-%d %H:00") self.hourly[hour_key]["count"] += 1 self.hourly[hour_key]["speeds"].append(speed) self.counted.add(track_id) records.append((track_id, hour_key, speed)) return records这个模块里,window=10表示取最近10个轨迹点做速度估计;max_speed=6.0是速度上限过滤,超过就丢弃;counted集合保证一条轨迹只被统计一次。像素坐标到世界坐标用的是底部中点而不是检测框中心,这一行的差别在最终测速精度上影响巨大。代码里没有做轨迹的ID切换清理,实际使用时还要监听新出现的track_id,立刻清空对应buffer,否则会把新轨迹的坐标差算进旧轨迹的位移里。
4.4 进出方向判定与CSV输出
方向判断我有一个简单可靠的办法:取ROI两侧的虚拟线,用叉积判断轨迹起始点和终点分别在线哪一侧,符号变化就说明轨迹穿过了虚拟线,结合两侧的方向定义就能得出“进”还是“出”。叉积的正负只代表左右,不依赖距离绝对值,ROI画得不太规则也不影响判断。
统计结果写CSV时,我习惯按小时聚合:每小时一行,包含方向、人数、平均速度、中位速度。速度分布不要只用平均值,行人场景里中位数比平均数稳健——一个人慢速徘徊就能把平均值拉低0.3m/s,中位数基本不受影响。字段按需扩展成日期、小时、方向、人数、平均速度、中位速度即可,这个格式直接交给后端或存档都够用。
5. 避坑与调参:行人测速统计的五个典型问题
5.1 ID switch让速度从1.2跳成3.8
现象:行人A从画面右边走进ROI,走到中间时被一个骑电动车的人挡了一下,屏幕上的track_id从17变成了23,速度输出从1.2m/s跳到了3.8m/s。
原因:遮挡让ReID特征匹配失败,Deepsort在max_age耗尽后终结旧轨迹,同时新轨迹在旧轨迹的预测位置附近诞生,两个轨迹坐标发生跳变。速度模块没有感知到轨迹切换,把两个轨迹的坐标差算进了同一段位移。
解决:在SpeedStats里发现某个track_id是上一帧没有的新ID时,立即清空该ID的buffer,不要让它继承任何历史窗口。同时把max_age从30调到45,给遮挡留出更多恢复时间。这两个手段合起来,ID switch对速度统计的影响能消掉大半。
5.2 人在边界线上踱步被反复计数
现象:ROI的虚拟线附近有块减速带,行人走到那里习惯性停顿,只要他在虚拟线两侧来回挪一步,计数器就加一次,10分钟攒了20多次计数。
原因:计数逻辑只判断了轨迹首尾两点是否在线的两侧,没有给轨迹加“只计一次”的约束,也没有滞回机制,瞬时抖动会被当成一次穿越。
解决:给每条轨迹加counted标志,第一次满足穿越条件后锁死,不再参与计数。同时在穿越判定里加连续帧数约束:要求轨迹在进入侧连续出现N帧,N取5左右,才认为真的在通过而不是抖动。这样在边界徘徊的人最多计一次,不再刷数据。
5.3 远端速度系统性偏小:标定参考点没铺满
现象:同一段路,近端摄像头下方测出来是1.3m/s,远端测出来只有0.7m/s,两个位置差了近一倍,怎么看都不合理。
原因:求单应矩阵时只用了ROI近处的几个参考点,H在近处拟合得好,向外推到远处就发散。斜视摄像头下,画面像素在远端对应的地面距离远大于近端,H推到远端后位移被压缩了。
解决:标定参考点必须铺满ROI整个区域,尤其是远端角落。我一般取6到8个点,沿通道两侧均匀分布,让H的映射误差被均摊。改完参考点后出现了一个新现象:远端速度反而比近端略高,因为远端人的脚底点在图像上太小,检测框底部中点偶尔偏高,投影到地面后有一段无中生有的额外位移。这个只能靠速度上限过滤兜底。
5.4 fps一抖速度就飞:用帧号当时间的后果
现象:系统在高负载下速度值经常窜到7m/s、9m/s,看起来像有人跑步,但画面里全是散步的人。
原因:第一版用帧号差除以fps计算时间间隔,fps取的是视频平均值。负载高时某一帧处理要花0.25秒,帧号差却只计了1,分母用了1/24秒,时间被缩小到1/6,速度直接放大6倍。
解决:全部改成时间戳。视频文件就用cap.get(cv2.CAP_PROP_POS_MSEC),实时流就用time.time()。排查时可以打印相邻帧间隔验证:
prev_ts = time.time() while cap.isOpened(): ret, frame = cap.read() now = time.time() print(f"帧间隔: {now - prev_ts:.3f}s") prev_ts = now如果这个值波动超过50%,说明fps不可靠,速度计算必须使用时间戳。改完之后同样的负载下,速度毛刺从7m/s降回1.3m/s附近。这个问题排查了整整一个下午,最后发现不是算法问题,是计时单位的问题。
5.5 检测框底部中点漂移,投影轨迹转向
现象:行人在画面里贴着通道边走,头顶被树枝挡住半秒,检测框底部中点从脚下漂到路牙外,投影出的世界坐标轨迹瞬间拐了一个直角,速度也明显偏大。
原因:YOLO在遮挡时检测框不稳定,底部中点不再对应真实落脚点。所有后续地面投影和速度计算都基于这个点,点漂了整套结果就跟着漂。
解决:在进入速度计算前,对底部中点的世界坐标做一个5帧滑动中位数,把单帧突变消掉。中位数对尖刺完全免疫,不会把一次漂移平滑成一段假位移。再配合max_speed=6.0的上限过滤,即使中位数来不及处理,异常速度也会被丢弃。这两层兜底是速度统计的后悔药,务必都保留。
6. 验证方法、参数速查表与标定结果复用技巧
6.1 用已知距离走一遍,误差控制在10%以内
标定和速度算法都写完,第一件事不是接真实摄像头,而是做一次端到端验证。找一段10米平直地面,两端各放一个标志物,请一个人正常步速走过去,用秒表记录时间,算出参考速度。同时让系统跑这段视频,输出测速结果。两者对不上就返回去检查H、检查底部中点、检查时间戳。我的经验是误差在10%以内算合格,超过10%优先怀疑标定而不是跟踪,因为Deepsort在行人场景的轨迹精度基本够用,误差大头在几何换算。
6.2 把H矩阵和参数固化:启动即用、避免重新标定
每次启动都重新选点标定很累,而且手选点有偏差,结果不稳定。我习惯把标定结果存成JSON文件,运行主程序时直接加载,摄像头位置动过才重新标定:
import json import numpy as np with open("calib.json", "r") as f: cfg = json.load(f) H = np.array(cfg["homography"], dtype=np.float32) roi = np.array(cfg["roi_polygon"], dtype=np.int32)JSON里放三样东西:roi_polygon顶点、homography矩阵、world_pts参考点。这样每次启动都是同一套标定结果,不会再因为手滑多点或少点导致结果漂移。
6.3 一张参数速查表收拢所有配置
| 配置项 | 推荐值 | 作用 |
|---|---|---|
| min_confidence | 0.3 | 检测置信度下限 |
| max_age | 30-45 | 遮挡后轨迹保留帧数 |
| max_cos_dist | 0.2 | ReID特征匹配阈值 |
| nms_max_overlap | 0.7 | 检测框重叠去重阈值 |
| speed_window | 10 | 速度计算窗口帧数 |
| max_speed | 6.0 | 速度上限过滤(m/s) |
| count_confirm_frames | 5 | 计数确认连续帧数 |
| 标定参考点数 | 6-8 | 单应矩阵计算点数 |
这组参数适合户外行人场景。换成室内密集人群,max_age要降,count_confirm_frames要升,因为密集场景ID更容易丢、边界抖动更容易发生。没有一套参数能通吃所有摄像头,但按这张表起步,配合第5章的避坑经验,足够在半天内跑出一个稳定版本。我做这类项目有个固定习惯:每次改完标定,先录一段10秒匀速行走视频,不急着跑完整流程,先回放投影轨迹看是否贴地。投影曲线越平滑,后面调参省的时间越多。希望帮到你。
本文还有配套的精品资源,点击获取