☰
Python人流量上下行计数实战:YOLOv8+追踪+虚拟线实现双向客流统计
2026/10/10 17:41:44 网站建设 项目流程

简介:面向Python开发者和计算机视觉入门者,这份人流量计数与上下行方向统计资源围绕OpenCV实现了一套完整的人数统计方案,适用于零售门店、交通枢纽、公共场所等需要分析人员流向的场景。压缩包共20个文件、约138MB,包含可直接运行的Python源码(主程序people_counter.py、跟踪模块centroidtracker.py)、4段MP4测试视频与2段AVI输出结果,以及MobileNet-SSD和YOLO的模型文件(caffemodel、prototxt、cfg、names),另有README说明文档,目录结构清晰,便于按需检索和二次开发。项目覆盖背景减除、高斯滤波、二值化、轮廓提取、连通组件分析等图像处理流程,并演示了基于质心跟踪与上下行边界判定的计数逻辑,让读者完整体会从视频帧读取到方向统计的工程实现。同时提供MobileNet SSD与YOLO两种检测思路的对照,适合希望深入掌握人流统计实战技巧的学习者,也可用于课程设计或项目预研。目前已有511人学习浏览,是入门计算机视觉项目的不错参考。

1. 人流量上下行计数,难点从来不是“数人”而是“认方向”

python人流量计数人数统计上下行计数统计,说白了就是给监控画面装一双眼睛:先认出画面里的人,再把每个“人”跟住了,判断他往哪个方向走,最后分成上行、下行两组人数。做商场出入口的进店率、地铁闸机的换乘方向、展馆通道的双向客流,全是一套逻辑。很多新入行的工程师以为这活儿是“目标检测加一个计数器”,真跑去联调才会发现,方向判错、ID跳变、漏检叠加才是让数字对不上账的元凶。这篇文章面向需要用普通摄像头和一台能跑Python的机器做双向计数的工程人员,我会把检测、追踪、虚拟线方向判定、参数调优和验证方法一条线讲完,保证你能本地复现一个最小可用版本,再把误差逐步压到10%以内。

2. 技术选型先于编码:人流量计数为什么需要“检测加追踪”两件套

2.1 检测器选型对比:YOLOv8、MediaPipe 还是老牌 HOG

上下行人流量计数的第一步是“认出人”。常见方案有三种:HOG+SVM、MediaPipe、YOLO 系列。HOG 是传统手工特征,对固定姿态、固定场景的闸机还能打,但行人稍微遮挡、转身、背包就漏检。MediaPipe 做人脸和姿态快,但它是“骨骼点优先”的思路,背对摄像头、人群遮挡时经常给不出稳定框,用来做方向计数会浪费大量算力在重识别上。

我的建议是直接上 YOLO,预算有限用 YOLOv8n,机器还行就 YOLOv8s。COCO 预训练模型里 person 类别(class_id=0)的泛化能力已经过大量场景验证,而且 ultralytics 包把推理封装得很干净,省去自己拼前处理的麻烦。下面这个表是我在选型时常对合作方讲的对比,方便一眼看到差距:

方案推理速度(同机器同分辨率)遮挡/姿态变化鲁棒性工程化成本
HOG+SVM快差中等
MediaPipe快中等中低
YOLOv8n较快好低
YOLOv8s/m中/慢更好低

2.2 追踪器选型:IOU 追踪、ByteTrack、DeepSORT 怎么取舍

光有检测框不够,因为“上下行”要求知道同一个人的运动方向。帧与帧之间没有 ID 联系,你就无法判断他是从线这边走到那边,还是画面里新冒出来一个人物。这一步靠追踪器解决,它会为每个检测目标分配唯一 track_id,并维护它的历史轨迹。

追踪器有两条路线:IOU 匹配的轻量方案,和带重识别特征的 DeepSORT / ByteTrack 方案。IOU 匹配简单直白,用两个检测框的交并比判断是不是同一个人,帧间位移不大的固定机位场景完全够用,代码量最小,也最容易暴露逻辑错误。DeepSORT 引入外观特征做相似度匹配,解决遮挡后 ID 恢复的问题,但要多跑一个特征提取模型。ByteTrack 比较讨巧,它会把低置信度检测帧也纳入关联,对密集人群更友好,代价是多了几个超参。我的经验是:第一版永远先用 IOU 追踪跑通,看漏检和 ID 跳变主要发生在哪些帧,再决定要不要升级追踪器,不要一上来就上重武器。

2.3 上下行方向判定的核心:虚拟线与叉积符号变化

方向判定的工程实现,主流做法是“虚拟线计数”:在画面固定位置画一条线,人的中心点从线的一侧跑到另一侧,就算一次穿越,穿越方向就是上下行。这里的关键不是“前后帧 y 坐标哪边大”,而是判定点如何在线的两侧切换。

数学上用一个叉积判断点在直线哪一侧:取线的两个端点 A、B,对当前点 P 计算向量 AP 与 AB 的叉积。结果为正是一侧,为负是另一侧,等于 0 说明正好压在线上。上一帧和下一帧的叉积正负不同,说明发生了跨越。方向则由“从哪一侧跨过来”决定,这样不会因为摄像头倾斜造成的坐标缩放而误判。实现我会在下一章给出完整代码,这里先立住思路:虚拟线位置必须保持静止,摄像头一旦移动或转动,这条线的世界坐标就变了,计数立刻失真。

3. 用 YOLOv8 和简易追踪跑通最小计数流程:从配置环境到方向计数

3.1 环境配置:先解决 Python、numpy、cv2 这几个基础依赖

很多项目死在开头不是算法难,而是环境版本对不上。建议用 conda 建一个干净环境,Python 版本选 3.9 到 3.11 之间,我习惯用 3.9,兼容性最稳。安装时把 ultralytics、opencv-python、numpy 一起装,别一个个试,省得后面 import 时报缺库。下面这段是每次新机器我都要跑一遍的命令:

conda create -n flowcount python=3.9 -y conda activate flowcount pip install ultralytics opencv-python numpy

命令逻辑是先把 Python 环境和系统环境隔离开,再用 pip 装三个核心库。注意 ultralytics 会自动拉起 PyTorch 和 torchvision,在 CPU 机器上也能跑,只是推理慢一些。装完后可以敲一句python -c "import cv2, numpy, ultralytics; print('ok')"验证是否导入成功,这一步能过滤掉大半“装了半天结果 import 失败”的隐性问题。

启动代码也很固定:读取视频文件或摄像头流,拿到分辨率、帧率这些元数据。后面写输出视频、做跳帧处理都要用到它们:

import cv2 from ultralytics import YOLO model = YOLO("yolov8n.pt") # 首次运行会自动下载权重 cap = cv2.VideoCapture("test.mp4") # 换成你自己的视频 fps = cap.get(cv2.CAP_PROP_FPS) width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

这里的参数说明:CAP_PROP_FPS拿真实帧率,后面设置跳帧和“冷却期”都依赖它;width/height除了用于初始化 VideoWriter,也方便你按画面高度百分比设置虚拟线的 y 坐标,而不是写死像素值。

3.2 一个能跑的最小检测追踪循环

环境就绪后写主循环。每帧先丢给 YOLO 推理,取出类别为人、置信度达标的框,再交给追踪器更新 ID。为了把逻辑说清楚,我用一个手写的 SimpleTracker 演示 IOU 匹配的核心思想;实际项目里你可以直接换成 ByteTrack,但理解这个类的三个字段对调参很有帮助:

class SimpleTracker: def __init__(self, iou_threshold=0.3, max_lost=15): self.tracks = {} # track_id -> (bbox, lost_count) self.next_id = 0 self.iou_threshold = iou_threshold self.max_lost = max_lost def update(self, detections): # detections: list of (x1, y1, x2, y2, score, class_id) if not self.tracks: for det in detections: self.tracks[self.next_id] = [det, 0] self.next_id += 1 return self.tracks for track_id, (old_bbox, lost) in list(self.tracks.items()): best_iou, best_det = 0, None for det in detections: iou = compute_iou(old_bbox[:4], det[:4]) if iou > best_iou: best_iou, best_det = iou, det if best_iou >= self.iou_threshold: self.tracks[track_id] = [best_det, 0] else: self.tracks[track_id] = [old_bbox, lost + 1] # 为没匹配到任何旧轨迹的检测创建新 ID current_bboxes = [v[0] for v in self.tracks.values()] for det in detections: if det not in current_bboxes: self.tracks[self.next_id] = [det, 0] self.next_id += 1 # 清理丢失超过阈值的轨迹 self.tracks = {tid: v for tid, v in self.tracks.items() if v[1] < self.max_lost} return self.tracks

这个实现对每个旧轨迹只找一个 IoU 最大的新检测来配对,compute_iou是标准的两个矩形交并比计算,你自行实现即可。参数说明:iou_threshold决定两个框重叠多大算同一个人,一般取 0.2~0.4,行人快速移动时放宽到 0.2;max_lost允许一个目标连续丢失多少帧后放弃它,建议按 0.5 到 1 秒帧数折算。要注意这个简化版本没做“一个新检测只能匹配一个旧轨迹”的双向约束,所以两人靠近时会偶尔共用一个 ID;正式项目里建议用线性分配或贪心匹配补上这层约束。

主循环调用方式如下。classes=[0]只保留 person 类别,conf=0.35过滤低置信度框,这两项对计数准确性影响极大:

import cv2 from ultralytics import YOLO model = YOLO("yolov8n.pt") cap = cv2.VideoCapture("test.mp4") tracker = SimpleTracker(iou_threshold=0.3, max_lost=15) while cap.isOpened(): ret, frame = cap.read() if not ret: break results = model.predict(frame, conf=0.35, imgsz=640, classes=[0], verbose=False) dets = [] for r in results: for box in r.boxes.data.tolist(): x1, y1, x2, y2, score, cls = box dets.append((x1, y1, x2, y2, score, int(cls))) tracks = tracker.update(dets) # 这里接着做跨越判定和计数,见 3.3

逐帧检测在 CPU 上可以跑但比较慢,如果视频里人流速度不快,改成每两帧检测一次、中间帧沿用上一帧的 tracks,能省不少时间。判断依据看你的业务:闸机口人员移动快,跳帧会导致“人一步跨过线没被记录”的风险,这种情况尽量逐帧跑。

3.3 虚拟线跨越判断:用叉积避免“y 坐标比大小”的误判

现在把方向判定接进去。我前面提到要用叉积判断点在线的哪一侧,这里给出可直接用的函数。LINE定义成画面里一条线的两个端点,我习惯放在画面高度 1/3 到 1/2 的位置,太低会让追踪还没稳定就计数,太高会漏掉低头看手机的人:

def cross_point_side(p, a, b): # 返回叉积正负:>0 一侧,<0 另一侧 return (p[0] - a[0]) * (b[1] - a[1]) - (p[1] - a[1]) * (b[0] - a[0]) def is_crossing(prev, curr, line_start, line_end): prev_side = cross_point_side(prev, line_start, line_end) curr_side = cross_point_side(curr, line_start, line_end) if prev_side * curr_side < 0: # 以“从线正侧跨到负侧”记为 up,具体方向自行约定 direction = "up" if prev_side > 0 else "down" return True, direction return False, None

“prev 和 curr”分别是目标在上一帧和当前帧的中心点。叉积符号不变说明没有跨越;符号由正变负或由负变正,就计数一次,方向由穿越前所在侧决定。这里的核心是:虚拟线两端点定义好后,线把二维平面分成两侧,方向只与“从哪边来”有关,跟画面里人是变大变小、镜头畸变都无关,这也正是叉积方案比“比较前后帧 y 坐标”更稳的原因。y 坐标比较在斜视摄像头下非常容易因为检测框轻微抖动而误判,叉积则只在真正跨线时输出一次。

最后在每帧循环末尾,把跨越事件累加并画到画面上:

count_up, count_down = 0, 0 last_centers = {} LINE_START, LINE_END = (100, 300), (700, 300) for track_id, (bbox, _lost) in tracks.items(): x1, y1, x2, y2, _s, _c = bbox cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 if track_id in last_centers: crossed, direction = is_crossing( last_centers[track_id], (cx, cy), LINE_START, LINE_END) if crossed: if direction == "up": count_up += 1 else: count_down += 1 last_centers[track_id] = (cx, cy) cv2.line(frame, LINE_START, LINE_END, (0, 255, 255), 2) cv2.putText(frame, f"UP: {count_up} DOWN: {count_down}", (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2)

这段逻辑里last_centers是每个 track_id 上一帧的中心点字典,只有目标持续存活时跨越判定才有意义。注意人刚进入画面时没有上一帧,首帧不会计数,这是正常的,不需要额外处理。

4. 上下行计数的 6 个关键参数:置信度、IOU、线位置和性能预算怎么设

4.1 检测置信度与 IoU 阈值:漏检和误检的对赌

置信度conf是最先要调的参数,它是检测框属于 person 的自信程度。设太高,远处小目标和半遮挡行人被丢掉;设太低,背景里的假人形、广告牌上的模特全被当成行人。我对固定机位的监控视频一般从 0.35 起步:室内光线均匀、人形完整可试着上调到 0.4;门口逆光、夜间红外场景降到 0.25~0.3。参数之间是联动关系,降了conf就要靠后处理过滤掉面积异常的检测框,否则误检直接变成重复计数。

IoU 阈值影响追踪器对“同一个人”的判定。iou_threshold越大,要求两个框重叠度越高才算同一轨迹,容易把快速移动的行人拆成多个 ID;越小,容易把靠近的两个人并成一个 ID。我的经验区间是 0.2~0.4:人流稀疏、行走方向一致的通道取 0.3;闸机口人挨着人、方向混乱的现场取 0.2 并配合 ByteTrack 的匹配策略。还有一个容易被忽略的细节:检测框面积过滤。设定一个最小框面积,比如小于 0.1% 的画面区域直接丢弃,能很干净地滤掉 YOLO 偶尔吐出来的小碎框。

4.2 丢失容忍帧:ID 消失多久算“一个人结束”

max_lost决定一个目标连续多少帧没有被匹配到时,追踪器才有权忘记它。这个值按视频帧率换算成“秒”更容易理解:15 帧约等于 0.5 秒。行人被短暂遮挡、出画再入画,如果丢失容忍太短,旧 ID 直接销毁,人再次出现会拿新 ID,等于一个人被计数两次;容忍太长,旧轨迹会“占用”一个已经走掉的人的资源,新来的行人可能错误地续上旧 ID,方向判定随之串号。

我处理这个问题时有一个原则:计数场景目标运动是单向且线性的(走路、通过闸机),丢失容忍取 0.5 秒足够。如果现场有两人擦肩这种复杂遮挡,把 shorts 提升到 1 秒,并换用带卡尔曼滤波预测的追踪器,给每一帧补一个预测位置再匹配,这样短暂遮挡期间旧轨迹也能预测着往前走。

4.3 虚拟线位置与方向约定:放哪里、怎么定正反

虚拟线放画面什么位置,直接决定计数稳定性。放在画面最上方或最下方都有问题:画面边缘是行人刚进入或即将离开的区域,检测框经常只有半截,中心点上下跳得很厉害;放太中间又会把徘徊观望的人反复跨线,导致一个人被计三次。

我一般把线放在画面垂直方向 35%~55% 的位置,且尽量让人的行进方向与线的法线方向夹角大。线是水平的时候,判断“从左到右”还是“从右到左”;线是垂直的时候,判断“从上到下”还是“从下到上”。方向约定必须在代码注释里写死,否则一周后看结果根本分不清 up 是进门还是出门。另一个常见做法是画双线(两道相邻虚拟线),只有依次穿过两条线才算计数一次,可以过滤掉在线上来回踱步的噪声,代价是逻辑和帧率要求更高。

4.4 性能预算:CPU 机器上怎么把处理速度提上去

很多实际项目跑在现场的工控机上,没有独立显卡。YOLOv8n 在纯 CPU 上逐帧推理,速度大概是每秒几帧到十几帧,取决于机器核数和画面分辨率。如果帧率低于视频本身,逐帧处理会越来越慢,最后内存暴涨。两个解法:一是跳帧处理,每两到三帧检测一次,对常规步行速度影响不大;二是把权重导出成 ONNX,用 onnxruntime 推理,省掉 PyTorch 的调度开销。导出命令是:

yolo export model=yolov8n.pt format=onnx opset=12

导出后用 onnxruntime 加载,前后处理逻辑不变。对于多点位部署,ONNX 权重还能直接交给 TensorRT 或 OpenVINO 进一步压缩延迟,后面接的计数模块不用改,这是成本最低的性能优化路径。

5. 落地避坑:5 个让计数翻车的现场问题与排查办法

5.1 人群密度上来后计数疯狂上涨

现象:早高峰地铁口人流一大,上行人数比实际多了将近一倍,画面里一个人被画了两三个框。原因:密集场景下行人间互相遮挡,同一个行人被检测器切成上下两段,追踪器又当成两个新 ID 维护,跨线时同一时刻计了两三次。解决:把conf上调到 0.4 以上,过滤掉低置信度的残缺框;检测后加一个非极大值抑制去掉高度重叠的同类框;再按最小面积阈值丢弃过小的碎框。调完之后重新录一段密集场景视频对比,误差通常会明显回落。

5.2 同一个人在虚拟线附近徘徊,被反复计数

现象:门店入口有人站在线旁边看手机,身体前后晃动,计数从 1 涨到 5。原因:虚拟线计数只认“中心点跨线”,来回跨线就反复触发。解决:给每个 track_id 加一个冷却期,计数后 2 秒内不允许再次计数。代码里存一个last_count_frame字典,触发计数时记录当前帧序号frame_id,下一次要计数前先检查frame_id - last_count_frame[track_id] > 2 * fps。这个 2 秒数值按行人正常通过速度设,走得慢的通道可以放宽到 3 秒。

5.3 斜视摄像头下 ID 频繁丢失,方向判定一串错一串

现象:侧装摄像头视角中行人相互遮挡严重,一个 ID 刚建立两秒就丢,再出现又领了新 ID,导致“一个人被当成两个人”朝同一个方向计数。原因:纯 IOU 匹配在目标外观变化快时失效。斜视角度下人的宽高比不断变化,检测框和上一帧重叠度急剧降低。解决:升级到带外观特征的追踪器,比如 DeepSORT 或 ByteTrack;同时把iou_threshold放宽到 0.2。如果现场仍然频繁丢 ID,考虑把摄像头安装角度调正,让行人目标尽量正对镜头,侧面安装永远是追踪的天敌。

5.4 逆光、夜间场景检测框时有时无

现象:傍晚背光时,检测器前排人还能出框,后排人全部丢失;夜间灯光下远处行人隔几帧就消失一次。原因:模型训练数据里低照度样本覆盖有限,加上相机自动曝光导致人脸和服装过暗。解决:摄像头开启宽动态或补红外光;图像预处理阶段先做 CLAHE 直方图均衡化,把暗部细节拉出来再进模型。如果现场无法增加补光,换 YOLOv8m 权重会比 n 权重好一些,但换模型后要重测置信度阈值,不能沿用原来的参数。

5.5 短时误差不大,全天累计后账对不上

现象:单测 10 分钟误差只有 3%,但一天 12 小时运营下来,系统计数比人工盘点少了三四百人。原因:早晚高峰漏检率高于平峰,系统性偏差在长时间累计中被放大。解决:按时段分场景评估,早高峰和晚高峰各采 5 分钟真值,单独算误差;如果只是局部时段偏差大,针对那些时段调整置信度或加跳帧策略。更工程化的做法是给每个通道设置一个校准系数,用一段时间的人工真值与系统计数的比值做修正,但要注意这是兜底方案,不能替代算法本身的修调。

6. 验证与误差校准:把计数误差压到 10% 以内的复现流程

6.1 从真值统计到分方向误差计算

系统跑通后,第一件事不是看效果,而是建真值。挑一段光线正常、包含至少 200 人次通行的视频,用播放器 0.25 倍速手动数出“上行多少、下行多少”。这个数据就是标尺。然后把系统对同一段视频的输出导出,按方向比对。人与人之间方向相反会被一正一负抵消,所以一定要分方向统计误差,不能只看总数误差。公式很简单:方向误差 = (系统计数 - 真值) / 真值,正数偏高,负数偏低。 |

6.2 三个误差来源分开看:漏报、误报和方向错

做误差分析时,把每个差异拆成三类:漏报(人通过了但系统没计)、误报(没人通过但系统计了)、方向错(人过了但方向归到另一边)。三者原因和处理路径完全不同。漏报多说明置信度阈值偏高或线位置太低;误报多说明背景干扰严重,要收紧阈值和面积过滤;方向错多说明虚拟线位置或方向约定有问题,重点检查线附近有没有徘徊、多人并行等情况。这一步没必要做逐帧精确匹配,按总数拆类即可,迭代几次后误差通常能压到 10% 以内。

6.3 一个可以长期复用的验证脚本

把以上流程固化成脚本,以后每个新现场都用同一套逻辑验证,避免拍脑袋。脚本只需要输入真值和系统输出两个字典:

def eval_flow(gt: dict, sys_out: dict) -> dict: result = {} for key in ("up", "down", "total"): g, s = gt[key], sys_out[key] err = (s - g) / g * 100 result[key] = {"gt": g, "sys": s, "err_percent": round(err, 1)} return result print(eval_flow( {"up": 148, "down": 132, "total": 280}, {"up": 156, "down": 125, "total": 281} ))

这个脚本会输出每个方向的误差百分比。我个人的习惯是:误差压到 10% 以内算合格,5% 以内可以交付。如果某个方向始终偏高,先查摄像头视角和虚拟线位置,再回头检查帧率和追踪丢失容忍,这两个位置是大多数“玄学误差”的真正来源。

之前有一次在商场入口做双向计数,视频回放显示一切正常,但上行误差一直在 15% 左右。排查了三天,最后发现是虚拟线画得太靠近画面的客流汇聚区,两个人并排走的时候追踪器把两人的 ID 互换了,方向自然串了一半。后来把线往下移了 30 像素,误差直接掉到 4%。这类问题没有捷径,只能靠分方向真值一帧一帧盯。希望这个验证流程能帮你少走这段弯路。

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

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

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

立即咨询