
简介基于DeepSORT和OpenCV的ROI区域行人测速统计系统是一份面向计算机视觉初学者和智能监控开发者的完整Python项目。系统以ROI区域行人速度检测与统计为核心围绕视频帧预处理、目标检测、跟踪与速度估算展开。实现中采用DeepSORT多目标跟踪算法通过深度特征提取与匹配策略优化降低漏跟和误跟OpenCV则承担图像读取、灰度化与去噪等环节二者配合可直接复现从检测到测速统计的完整流程。压缩包共20个文件以10个py脚本为主对应目标检测、跟踪器、速度估计和数据整理等模块另有9张png图片展示输出效果1份md文档说明运行方式整体大小8.72MB。资源附有README说明便于快速上手适合课程设计、毕设或交通监控相关场景的研究参考也能用于交通路口行人过街速度监控、商场与车站人群通行分析目前已有38人学习。1. 用 DeepSort 和 OpenCV 圈定 ROI 做行人测速比单纯计数难在哪在园区出入口、商场通道或者工地卡口最常被问的问题不是“库里来了几个人”而是“这个人走了多快这条通道的人流什么时候最挤”。单纯用 OpenCV 做目标检测计数只能回答数量想得到速度就必须保证连续帧里跟踪的是同一个行人。基于 Deepsort 和 OpenCV 的 ROI 区域行人测速统计系统核心思路是在画面中先用鼠标圈出一块多边形兴趣区用检测器找出行人再用 Deepsort 为每个人维护连续 ID最后把中心点在 ROI 里的跨帧位移换算成真实速度。这套方案适合已经跑通过检测、想往跟踪和统计上走的工程师也适合用 OpenCV 图像处理项目做安防原型的同学。动手前先理解一个反直觉点测速的误差大部分来自跟踪而不是来自速度公式。2. DeepSort 跟住人、OpenCV 算好图这个系统由哪几块拼出来2.1 检测、跟踪、测速三层拆解为什么盯住同一 ID 是测速的前提先说我最后落地的代码结构。它分三层第一层是检测器我用 OpenCV dnn 加载 YOLOv4-tiny输出每一帧的人形框第二层是 DeepSort 跟踪器它把当前帧检测框和上一帧轨迹做关联给每个人分配一个不会乱变的 track_id第三层是测速统计它只用跟踪结果判断中心点是否落在 ROI 多边形里是的话就累积位置算速度。三层各干各的好处是你可以随时把 YOLO 换掉或者把 DeepSort 换成改进版本测速统计完全不用动。为什么测速一定要靠跟踪想象一个行人从画面左边走到右边如果没有 track_id你只能看到一堆框无法知道这一秒看到的框和上一秒的框是不是同一个人。DeepSort 做的就是在卡尔曼滤波预测位置之后用匈牙利算法把检测框匹配到已有轨迹上。和早期 SORT 只靠 IoU 匹配不同DeepSort 额外提取了外观特征向量两个行人贴得很近时只要长相特征差异够大ID 也不容易交换。这个特性对 ROI 测速很重要因为在 ROI 边界经常出现半身框和遮挡单靠位置预测很容易串 ID。它和基于 OpenCV 的物体识别和计数项目共用同一套原型结构只是多了一个跟踪器。有人会问为什么 ROI 不放在检测之前直接从原图裁剪出兴趣区再检测我一开始也这么干后来发现两个问题。一是行人走进 ROI 时身体先被框截断检测框只有半个人高中心点剧烈晃动二是裁剪后的图像分辨率和背景都变了检测器会出现大量漏检。常见做法是检测全图、跟踪全图只在跟踪结果上做 ROI 判断。这样等行人真正进到 ROI 时他的轨迹已经预热了好几帧中心点也有了历史位置测速就平顺得多。2.2 ROI 用多边形而不是矩形鼠标点出兴趣区与 pointPolygonTest 的落地路径ROI 的形状我几乎不用矩形。原因很简单监控画面是有透视的人行道往往是斜着穿过画面矩形会把一大片墙壁也圈进来墙壁上的影子、头肩误检会全部参与测速。多边形可以用鼠标实时调整OpenCV 的 setMouseCallback 写这个交互脚本非常顺手这段脚本几乎是 OpenCV 图像处理项目里最常用的脚手架。下面这段代码是我做原型时在单帧上圈定 ROI 点用的你复制下来改个视频路径就能跑。它支持左键加顶点、右键撤销最后一个点、回车确认import cv2 import numpy as np roi_pts [] def on_mouse(event, x, y, flags, param): if event cv2.EVENT_LBUTTONDOWN: roi_pts.append((x, y)) print(add roi point:, x, y) elif event cv2.EVENT_RBUTTONDOWN and roi_pts: roi_pts.pop() print(remove last point:, len(roi_pts)) cv2.namedWindow(frame) cv2.setMouseCallback(frame, on_mouse) cap cv2.VideoCapture(demo.mp4) ret, frame cap.read() if not ret: raise SystemExit(read video failed) while True: show frame.copy() for p in roi_pts: cv2.circle(show, p, 3, (0, 255, 0), -1) if len(roi_pts) 1: cv2.polylines(show, [np.array(roi_pts, np.int32)], True, (0, 0, 255), 2) cv2.imshow(frame, show) key cv2.waitKey(1) 0xFF if key 13 and len(roi_pts) 3: # 回车表示至少三个点 break if key 27: # ESC 放弃 roi_pts.clear() break cap.release() cv2.destroyAllWindows() print(roi_pts , roi_pts)这段代码的逻辑很简单鼠标回调只负责维护坐标列表绘图循环每帧把点画出来预览。参数注意两点cv2.polylines 的第三个参数 closedTrue表示首尾相连画出来的图形就是闭合多边形waitKey(1) 在实时预览里够用但如果一帧一帧步进调试最好改成 waitKey(0)。roi_pts 最后保存成 Python 列表后续要转成 NumPy 数组作为轮廓。真正判断行人是否在 ROI 里用 cv2.pointPolygonTest它返回点在轮廓内外的距离大于 0 在内部等于 0 在边界上小于 0 在外面。我一般会取 bbox 的脚底中心点而不是几何中心因为行人测速应该以脚底落点为准头部会随走路上下摆动中心点噪声更大。判断代码就一行is_inside cv2.pointPolygonTest(roi_contour, (foot_x, foot_y), False) 0这里 roi_contour 是 shape 为 (N,1,2) 的 int32 数组直接来自鼠标点出的坐标列表。多边形的顶点顺序没有要求pointPolygonTest 不要求顶点方向。如果你要算多边形面积做可视化可以用 cv2.contourArea。到这步你已经有了 ROI 的描述方式下一步就是把检测器和跟踪器串起来。3. 从零到可跑检测器加 DeepSort 的最小串联与三个必调参数3.1 环境安装不要急着源码编译先把版本锁死先说环境。这个方向最常见的问题就是版本不一致。你网上搜安装 OpenCV 教程时会看到有人推荐 OpenCV 3.4.1 mingw64 下载、也有人推荐 Ubuntu 源码编译但我的建议是除非你要改底层算法否则一律用 Python 轮子。以下命令在 Ubuntu 和 Windows 的虚拟环境里都适用python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate pip install opencv-python4.5.5.64 opencv-contrib-python4.5.5.64 pip install numpy1.21.4 scipy1.7.3为什么锁 4.5.5.64因为很多 DeepSort 开源实现用了 scipy 的 linear_sum_assignment新 scipy 版本返回值类型有些差异NumPy 1.24 之后删除了一些旧 API容易冒出奇怪的 deprecation 报错。如果你在别处已经装过新版本import cv2 报 ModuleNotFoundError 时先检查是不是虚拟环境没有激活而不是重装系统 Python 包。在 Ubuntu 上很多人照着 ubuntu 配置 opencv 教程去编译源码折腾半天还容易把系统搞乱。除非你有自定义 OpenCV 算子的需求否则 pip 安装足够。如果你在 Windows 上看到 opencv 3.4.1 mingw64 下载我建议你冷静一下那个版本主要给 C 用户配上 mingw 编译环境代价不小Python 直接装 opencv-python 附带 DLL省很多事。当然如果你的 DeepSort 用到了 contrib 里的跟踪器记得把 opencv-contrib-python 也装上。打开这类 zip 项目包时我习惯先找两份关键东西检测器模型的权重文件以及 DeepSort 的封装类入口。前者决定能不能找到人后者决定 ID 跟不跟得住。这个检查习惯比逐行读代码更高效。3.2 用 OpenCV dnn 模块跑出检测框置信度阈值和 NMS 的参数配法检测器我惯用 YOLOv4-tiny因为它在 CPU 上也能跑到每帧 30 毫秒上下。OpenCV dnn 模块可以直接加载 cfg 和 weights不需要额外转 ONNX。下面是一个最小检测函数输出格式就是 DeepSort 需要的 [x1, y1, w, h, conf, class_id]。import cv2 import numpy as np net cv2.dnn.readNetFromDarknet(cfg/yolov4-tiny.cfg, weights/yolov4-tiny.weights) net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) layer_names net.getLayerNames() out_layers [layer_names[i - 1] for i in net.getUnconnectedOutLayers()] def detect_pedestrian(frame, conf_threshold0.4, nms_threshold0.5): h, w frame.shape[:2] blob cv2.dnn.blobFromImage(frame, 1/255.0, (416, 416), swapRBTrue, cropFalse) net.setInput(blob) outs net.forward(out_layers) boxes, confs, class_ids [], [], [] for out in outs: for detection in out: scores detection[5:] class_id int(np.argmax(scores)) if class_id ! 0: # COCO 数据集里 0 是 person continue if scores[class_id] conf_threshold: cx, cy, bw, bh detection[:4] * np.array([w, h, w, h]) boxes.append([cx - bw / 2, cy - bh / 2, bw, bh]) confs.append(float(scores[class_id])) class_ids.append(class_id) idx cv2.dnn.NMSBoxes(boxes, confs, conf_threshold, nms_threshold) results [] if idx is not None: for i in idx.flatten(): x1, y1, bw, bh map(int, boxes[i]) results.append([x1, y1, bw, bh, confs[i], class_ids[i]]) return results这段代码里blobFromImage 的输入尺寸统一缩放到 416x4161/255 是把像素归一化到 0 到 1。conf_threshold 不要设太低行人遮挡严重时 0.3 会给出一堆虚警这是后续测速飞点的主要来源我一般从 0.4 起步。nms_threshold 控制同一个行人重叠框的合并宽松度0.5 是 YOLO 系列常用值如果你发现两个人并排被合成一个框就降到 0.4。注意输出格式这里返回的 x1,y1 是左上角w,h 是宽高。DeepSort 的 update 方法一般要求 [x1, y1, w, h]而不是 [x1, y1, x2, y2]。很多人在这一步把坐标换算翻车把 x2、y2 当成宽高传进去跟踪器输出的中心点会整体偏移。这个格式在下一节代码里就能看到。3.3 把检测框交给 DeepSorttracker 输入输出与三个必调参数DeepSort 的开源实现非常多接口大同小异。我一般会用一个封装好的 DeepSort 类初始化时指定模型路径和几个关键参数。下面这段代码是我常用的调用方式不同版本字段名可能略有差异但逻辑一致from deep_sort import DeepSort tracker DeepSort( model_pathweights/deep_sort.pt, max_dist0.2, min_confidence0.4, nms_max_overlap0.6, max_iou_distance0.7, max_age30, n_init3, nn_budget100, use_cudaFalse ) def process_frame(frame): detections detect_pedestrian(frame) bbox_xywh [] confs [] for d in detections: x1, y1, w, h, conf, _ d bbox_xywh.append([x1, y1, w, h]) confs.append(conf) if len(bbox_xywh) 0: tracks tracker.update(np.array(bbox_xywh), np.array(confs)) else: tracks tracker.update(np.empty((0, 4)), np.empty(0)) results [] for tk in tracks: x1, y1, x2, y2, track_id, _ tk results.append((track_id, (x1 x2) // 2, (y1 y2) // 2)) return results这段代码有四个输入输出细节。第一检测框必须是 [x1,y1,w,h]跟踪器内部会转成 xyxy 或直接做卡尔曼滤波。第二没有检测框时也要调 update传空数组很多新手漏掉这一步导致 tracker 状态不更新。第三tracker 返回的坐标是 xyxy中心点要自己换算测速统计用的中心点建议取脚底中点而不是几何中心原因之前讲过。第四min_confidence 是独立于检测器 conf_threshold 的第二道闸我把它设成 0.4 是为了过滤掉那些置信度刚过检测线但跟踪特征很模糊的框。三个必调参数按影响程度排max_age 控制遮挡多久后删除轨迹行人密集场景建议 30 帧以上max_dist 控制外观特征相似度阈值默认 0.2 在低分辨率下容易断 ID我会放宽到 0.3n_init 控制新轨迹需要连续命中几帧才算数设 3 能避免边缘误检瞬间产生新 ID。如果你是想做 DeepSort 改进改动重心就在这三个参数和 ReID 特征上检测器反而不是主要矛盾。4. 速度模型与 ROI 测速策略像素坐标到真实速度的换算不是玄学4.1 标定换算用 findHomography 还是 solvePnP单位怎么统一把速度计算需要的坐标从像素变成米这一步最容易被低估。直接在画面上算像素距离会严重失真画面近处 10 像素可能只对应 5 厘米远处 10 像素可能是 30 厘米。OpenCV 里有两个常用工具findHomography 和 solvePnP。前者需要至少 4 组像素点与地面点的对应关系后者需要相机内参和三维地面点。我的经验是做原型先用 findHomography因为实现短、参数少如果相机畸变明显、测距精度要求高再用 solvePnP 解出旋转和平移把像素点反投影到地面。假设你在 ROI 内选了四个地面角点分别量好相互距离比如一个 5 米乘 4 米的矩形区域。标定代码import cv2 import numpy as np src_pts np.array([[100, 500], [700, 500], [900, 800], [200, 800]], dtypenp.float32) dst_pts np.array([[0.0, 0.0], [5.0, 0.0], [5.0, 4.0], [0.0, 4.0]], dtypenp.float32) H, _ cv2.findHomography(src_pts, dst_pts) def pixel_to_meter(px, py): p np.array([px, py, 1.0], dtypenp.float32) out H p return out[0] / out[2], out[1] / out[2]H 是 3x3 矩阵齐次坐标转换后要除以第三个分量才能得到平面坐标。这里的 dst_pts 单位是米输出速度自然就是米每秒。选点时尽量让四条边接近真实的道路边界不要取画面边缘因为广角镜头边缘畸变最严重。findHomography 的 RANSAC 模式能帮你剔除一个错误的对应点但前提是你给了至少 5 组点如果只有 4 组它相当于 getPerspectiveTransform。如果你在标定场景里遇到地面不是平面、有台阶的情况findHomography 就失效了。这时候要么把 ROI 拆成几个平面分别标定要么换 solvePnP。OpenCV 的函数 solvePnP 需要相机内参矩阵和畸变系数两套点变成 2D 像素点和 3D 地面点我一般在离线标定阶段用它在线阶段只用解算出来的 H 做快速换算。注意fps 一定要用 cap.get(cv2.CAP_PROP_FPS) 读不要信任你脑子里的默认值。4.2 速度计算滑窗长度、中值过滤和方向过滤拿到 H 之后测速就变成一个滑动窗口问题。我踩过的一个坑是直接取相邻两帧算速度结果 bbox 中心点抖一下速度就能飙到 20 公里每小时。后来改成保存最近 N 帧位置用首尾差除以时间差毛刺明显少很多。下面是一个简单的速度估计类from collections import deque class SpeedEstimator: def __init__(self, fps25, window10): self.fps fps self.window window self.tracks {} def update(self, track_id, pos): # pos 已经是单应矩阵换算后的地面坐标 (x, y) if track_id not in self.tracks: self.tracks[track_id] deque(maxlenself.window) self.tracks[track_id].append(pos) history self.tracks[track_id] if len(history) 2: return None (x0, y0), (x1, y1) history[0], history[-1] dt (len(history) - 1) / self.fps if dt 0: return None speed ((x1 - x0) ** 2 (y1 - y0) ** 2) ** 0.5 / dt if speed 5.0: # 超过 5 m/s 的飞点直接丢弃 return speed return None这里 fps 必须来自视频实际帧率而不是默认 25。我一般会在打开视频后用 cap.get(cv2.CAP_PROP_FPS) 读一次再在计时后做一次平均帧间隔校准。window 取 10 意味着约 0.4 秒的平均速度这个值能滤掉走路时的身体上下晃动。如果场景里有人跑动可以把窗口缩短到 5 到 6 帧否则跑步速度会被平均得偏低。方向过滤是很多人忽略的一步。如果只统计从东往西走的行人那么可以用位移向量的方向与预设方向做点积夹角超过 90 度就丢弃。这个过滤放在 ROI 判断之后能顺带把逆行的人排除统计口径更干净。写成代码direction np.array([x1 - x0, y1 - y0]) expected np.array([-1.0, 0.0]) # 假设统计从左向右 if np.dot(direction, expected) 0: return None这个过滤看起来简单但能显著净化数据。否则你在计算平均速度时会混入反向行人的负向位移速度被莫名拉低。4.3 统计口径平均速度、超速次数和通过量的表格化定义做完单条轨迹的速度估计最后一步是把统计口径固定下来。如果口径定义模糊同一个系统给不同人看会得出完全不同的报表。我一般用下面这个表统计项计算口径备注通过量中心点进入 ROI 后连续 3 帧在内计一次通过每一条 track_id 只计一次平均速度同一 track_id 在 ROI 内的所有速度样本取中位数平均值会被飞点带偏超速次数单帧速度超过阈值的样本数量阈值按场景设比如 2.5 m/s逗留时间进入 ROI 到离开 ROI 的帧数除以 fps用于分析停留行为这里最关键的是“通过量只计一次”。如果不按 track_id 去重行人在 ROI 边缘来回走动会被反复计数。代码里维护一个 set记录已经完成统计的 track_iddone set() SPEED_LIMIT 2.5 # m/s约 9 km/h # 在每帧跟踪结果里 if is_inside: estimator_result estimator.update(tid, metric_pos) if tid not in done and estimator_result is not None: done.add(tid) counts[pass] 1 if estimator_result and estimator_result SPEED_LIMIT: counts[overspeed] 1注意超速次数是样本级还是轨迹级要在报表里写明。样本级适合看瞬时爆冲轨迹级适合判断某个人是否全程超速。我一般两个都统计导出 CSV 时同时保留 track_id、帧号、速度和标签。5. 避坑Deepsort 丢 ID、ROI 边界误检、测速跳变的排查清单5.1 现象行人互相遮挡后 ID 频繁切换现象两个行人并排或迎面走过跟踪画面里 track_id 从 A 跳到 B测速轨迹断成两截。原因检测框重叠后IoU 匹配直接失效DeepSort 靠外观特征救急但监控画面里行人只有几十像素高ReID 特征区分度不够。解决把检测输入的分辨率从 416 提到 608让检测框更贴合完整身体max_age 提到 30 以上让短暂遮挡后的轨迹能续上如果还不行换更大的 ReID 网络。注意不要在测速代码里硬编码 IDID 在一定条件下就是会变的。5.2 现象ROI 边缘只露半身测速出现 120 km/h 飞点现象行人刚跨进 ROI速度曲线突然冲出一个 30 米每秒的尖峰随后又恢复。原因半身检测框的宽度、高度不稳定中心点或脚底点前后帧跳动几十像素换算成米就变成短距离内大位移。解决进入 ROI 后不要立刻测速先连续 3 帧中心点都落在多边形内才开始取速度样本速度计算窗口取至少 5 帧用首尾差而不是逐帧差分如果跳变来自 bbox 底部坐标改为取 bbox 底边中点并做一次轻量平滑。5.3 现象视频显示 25fps算出来速度整体偏大 20%现象同一段步行视频系统算出的平均速度比真实速度高了百分之十几。原因fps 用默认 25但实际视频是 30fps时间差算小了速度自然偏高。解决每次打开视频都用 cap.get(cv2.CAP_PROP_FPS) 读帧率更保险的做法是在视频帧上取两个已知时间戳算平均帧间隔。如果你的代码里直接写死 fps25换一台录制设备就翻车。这是最容易排查但最不容易被注意到的坑。5.4 现象相机在风里轻微晃动坐标逐帧跳变但跟踪没丢 ID现象画面里的静态背景都在抖行人速度出现周期性毛刺。原因监控立杆受风影响产生亚像素级位移整个画面的坐标整体偏了单应矩阵标定无法补偿这种摄影机抖动。解决如果只做统计把 ROI 设在画面中央并加长滑窗用中位数平滑能压掉一部分如果精度要求高需要先做全局运动补偿用背景特征点估计仿射变换再把每帧坐标对齐到参考帧。这是跟踪测速里常见的做法但会额外占用资源别轻易引入。5.5 现象OpenCV 版本升级后原来跑的代码突然报错现象换一台机器后 cv2.findContours 返回个数不对或 cv2.dnn.NMSBoxes 返回空数组导致 .flatten() 直接抛异常。原因OpenCV 4.x 之后很多 API 返回值变了NMSBoxes 在无目标时返回空 tuple 而不是 None。解决统一固定 opencv-python 版本写 NMS 时先判断 idx is not None 再取 flatten不要跟着网上老教程用 OpenCV 3.4.1 的 C 风格思考 Python 代码。Windows 用户不要在 mingw64 环境里浪费时间编译直接换 pip 轮子最快。6. 验证闭环用一段手机视频把测速统计跑通并核对误差整个系统写完后先别急着上真实监控。我会先用手机固定一段 10 秒视频在画面里量出一段 5 米的直线距离贴地标让一个朋友正常走过去。真实速度用秒表记录比如 5 米走了 2.3 秒真实平均速度约 2.17 m/s。然后跑自己的测速系统看输出的平均速度落在 1.9 到 2.4 之间就算通过。这一步能同时验证标定、跟踪和速度窗口是否正常。验证时有个小技巧把 speed_samples 按 track_id 打印到控制台同时把每个测试行人经过 ROI 的起始帧写进一个 CSV再用视频剪辑软件看回放。你不需要精确到每一帧只要对比系统给的通过量和视频里肉眼数出来的人数一致再做一次往返走看两个方向的速度是否对称。下面是一段导出 CSV 的骨架import csv with open(speed_report.csv, w, newline) as f: writer csv.writer(f) writer.writerow([track_id, frame_id, speed_mps, inside_roi]) for record in records: writer.writerow(record)我踩过的最后一个坑是没有先验证真实速度就直接调参数结果把超速阈值往错误方向调了半个下午。正确的做法是先录一段带真实速度的基准视频再拿它当回归测试集改一次代码就跑一遍阈值最好不要手调而是记录中位数后取百分位数。如果不一致优先查 H 矩阵标定和 fps这两处影响是全局的。这套习惯帮我省掉很多返工希望帮到你。本文还有配套的精品资源点击获取