☰
Python+OpenCV人流量计数:基于质心跟踪的上下行统计方案
2026/9/28 15:43:10 网站建设 项目流程

简介:该资源包以Python结合OpenCV实现人流量统计与上下行方向计数,面向计算机视觉初学者及零售、交通等场景的开发者,解决实时人数统计与行人方向识别问题。内含20个文件,涵盖5个Python源码、4段演示视频、2个AVI输出,以及MobileNetSSD和YOLO相关模型配置(caffemodel、prototxt、cfg、names),并配有README说明,模型与脚本相互配套,便于直接运行和二次改造。压缩包约138MB,已有511人学习使用。资源不仅提供people_counter.py主程序和centroidtracker.py跟踪模块,还包含多段进入/离开视频与演示动图,可帮助理解背景减除、轮廓检测、卡尔曼滤波等关键流程,并依照上下行边界设定实现双向计数。对于想快速落地人流统计方案或学习目标跟踪原理的开发者,这是一份结构完整的参考实现。

1. Python人流量计数:一个压缩包里的完整上下行统计方案

做零售门店和中小型站点的客流统计时,最头疼的不是检测不到人,而是算不清方向——进店和出店的人数一旦混在一起,后续的转化率分析就是一笔糊涂账。这份基于 Python + OpenCV 的人流量计数资源,把检测、跟踪、上下行计数串成了一条完整链路,压缩包里既有 MobileNet SSD 和 YOLO 两套检测模型,也有写好的people_counter.py主程序和质心跟踪模块,视频输入直接跑命令就能出结果。

它适合三类人:刚接触计算机视觉、想快速跑通一个完整项目的新手;门店或场馆需要做进出入统计、但不想从零搭系统的从业者;以及想研究检测跟踪落地细节、拿现成代码改业务逻辑的开发者。接下来我从项目文件拆起,把检测器选型、上下行判定逻辑、参数调优和踩坑记录一次讲透。

2. 项目文件拆解:检测器选型与两个入口脚本的分工

把压缩包解压后,第一件事不是急着跑命令,而是把people-counting-opencv-master目录里的文件角色认清。这个项目的结构不算复杂,但检测器部分容易让人犯迷糊——为什么既有mobilenet_ssd又有yolo-coco,两者到底是什么关系。

2.1 检测器选型:MobileNet SSD 与 YOLO 各管什么场景

mobilenet_ssd目录下的MobileNetSSD_deploy.prototxt是网络结构描述文件,MobileNetSSD_deploy.caffemodel是预训练权重,这是一套基于 Caffe 的 SSD 目标检测模型。MobileNet 作为骨干网络的特点是计算量小,CPU 上也能跑到可用帧率。yolo-coco目录里的yolov3.cfg是 YOLOv3 的网络配置,coco.names是 COCO 数据集的 80 个类别名称,YOLOv3 的精度比 MobileNet SSD 高,但对算力的要求也上了一个台阶。

项目默认走的是 MobileNet SSD 路线,people_counter.py里通过--prototxt和--model两个参数加载模型文件。检测器的作用是给每一帧画面里的人画出边界框,但只做检测还不够——单帧的框 ID 在下一帧会变,没法判断同一个人是否已经跨过计数线。所以项目里pyimagesearch目录下的centroidtracker.py和trackableobject.py才是上下行计数的关键。

文件角色可以用一张表看清:

文件/目录作用是否直接运行
people_counter.py完整版入口:检测 + 跟踪 + 上下行计数是
people_counter_initial.py初始版入口:只有单方向计数逻辑是
pyimagesearch/centroidtracker.py质心跟踪器,跨帧关联同一目标否
pyimagesearch/trackableobject.py可跟踪对象,记录质心轨迹与方向否
mobilenet_ssd/SSD 检测模型(默认)否
yolo-coco/YOLOv3 检测模型(备选)否
example_01.mp4等视频测试输入否
output/输出的带计数结果的视频否

2.2 两个入口脚本的差异:别拿初始版当完整版用

people_counter_initial.py这个文件很容易让人误以为是功能精简的性能优化版,其实它是功能残缺的初始版本,只实现了最基础的跨线计数,没有把上行和下行分开统计。而people_counter.py里通过每个TrackableObject实例维护的direction字段,把人员流向拆成了up和down两个独立计数器。

我一般会建议直接用完整版,但两个脚本保留的最大价值是提供了对照——你在改代码时如果发现计数逻辑异常,可以先跑初始版确认检测跟踪链路是否正常,再回到完整版排查方向判定部分。这种分层排查方式在调模型参数时特别实用,能快速定位问题出在检测环节还是计数环节。

3. 上下行计数的核心逻辑:质心跟踪与方向判定

人流量计数的难点不在「检测到人」,而在「怎么知道这是同一个人」以及「这个人往哪个方向走」。这两个问题分别由质心跟踪器和方向判定逻辑回答。先看跟踪是怎么做的,再理解上下行判定就水到渠成了。

3.1 质心跟踪:用欧氏距离把跨帧目标串起来

centroidtracker.py做的事可以概括为三步:提取当前帧所有检测框的中心点坐标;计算这些质心与上一帧已知对象质心之间的欧氏距离;用贪心匹配把距离最近的点对关联起来,让每个对象在连续帧里拥有稳定的 ID。

这段伪代码还原了它的核心思路:

# 核心思路:帧间质心最小距离匹配 # 上一帧有已知对象,当前帧有检测到的质心集合 # 遍历每个已存在对象,找距离最近的当前帧质心进行绑定 for object_id, centroid in self.objects.items(): # 计算该对象与当前帧所有候选质心的欧氏距离 distances = [ ((c[0] - centroid[0]) ** 2 + (c[1] - centroid[1]) ** 2) ** 0.5 for c in input_centroids ] # 取距离最小的质心索引 min_idx = distances.index(min(distances))

代码逻辑说明:这里的self.objects是上一帧确认过的目标字典,input_centroids是当前帧检测器输出的所有人物边界框中心点。每一帧都对每个旧目标做一次全量距离计算,然后把最小值对应的新质心分配给这个旧目标。由于距离计算量级是 O(n*m),当画面里同时出现几十个人时会有明显开销,这也是后面--skip-frames参数存在的意义——隔 N 帧检测一次,中间帧只做跟踪。

参数说明:如果视频帧里人特别密集,这种贪心匹配可能出现 ID 交换问题——两个人交叉走过时,跟踪器可能把 A 的 ID 换到 B 身上。这是质心跟踪的已知局限,简单场景够用,复杂拥挤场景需要换成 Deep SORT 这类带外观特征的跟踪器。

3.2 方向判定:计数线穿越法是怎么工作的

trackableobject.py里的每个对象实例保存了它的质心历史坐标和方向标记。方向判定的核心是预先在画面中设定一条水平计数线(代码里默认取画面高度的一半H // 2),然后比较同一目标在前后两帧的质心 y 坐标:

# 方向判定:比较前后帧质心与计数线的相对位置 # 假设计数线在画面高度一半位置 H // 2 # prev_centroid 是该目标上一帧的质心坐标 # centroid 是该目标当前帧的质心坐标 # H 为画面高度 if prev_centroid[1] < H // 2 and centroid[1] >= H // 2: # 上一帧在线上方,当前帧在下方 -> 下行 total_down += 1 obj.direction = "down" elif prev_centroid[1] >= H // 2 and centroid[1] < H // 2: # 上一帧在下方,当前帧在上方 -> 上行 total_up += 1 obj.direction = "up"

代码逻辑说明:判定条件不依赖检测框的宽度变化,只看质心点的 y 坐标穿越方向。上一帧质心在计数线上方、当前帧跑到下方,就判定为下行,反之是上行。注意这里专门比较了上一帧和当前帧两个位置,而不是只看当前帧在线的哪一侧,这样能避免目标从画面边缘直接出现时被误判方向。

参数说明:H // 2就是计数线的默认位置,这个值直接改 Y 坐标即可调整计数区域。实际场景里计数线不一定放在正中间——入口在画面上方就往下调,入口在下方就往上调,保证行人有一段完整轨迹可供判断。另外TrackableObject里还有个counted字段,确保每个目标只被计数一次,防止同一个人在线附近来回走动时被重复统计。

3.3 从检测框到计数结果的数据流

一条完整的计数链路是这样的:视频帧输入 → SSD/YOLO 检测出多个person类别边界框 → 非极大值抑制去掉重叠框 → 提取每个框的质心 → 交给centroidtracker.py做跨帧关联 → 取出每个目标的新旧质心 → 用 3.2 的逻辑判断穿越方向 → 更新total_up/total_down计数 → 把计数结果和轨迹画到帧上 → 写入输出视频。

这个流程在people_counter.py的while循环里逐帧执行。看到这里你应该明白,检测器决定「人在哪」,跟踪器决定「人是谁」,方向判定决定「人往哪走」,三者缺一不可。如果换一个单阶段检测模型,只需要改模型加载部分,跟踪和计数逻辑完全不用动。

4. 跑通与调参:命令行参数、模型切换与置信度设置

代码逻辑看完了,现在进入实操部分。这个项目的命令行参数设计得比较规整,跑通 demo 只需要一条命令,但要把参数含义和调整策略说清楚,不然换自己的视频时容易翻车。

4.1 一条命令跑通 example 视频

假设你在项目根目录下,先跑内置的example_01.mp4测试视频:

python people_counter.py \ --prototxt mobilenet_ssd/MobileNetSSD_deploy.prototxt \ --model mobilenet_ssd/MobileNetSSD_deploy.caffemodel \ --input example_01.mp4 \ --output output/output_01.avi \ --confidence 0.4 \ --skip-frames 30

参数说明:--prototxt和--model指定 SSD 的网络结构和权重;--input指定输入视频路径;--output指定结果视频写出路径;--confidence 0.4是检测置信度阈值,低于 0.4 的检测框会被丢弃;--skip-frames 30表示每 30 帧做一次目标检测,中间 29 帧只做质心跟踪,这是性能优化的关键参数。

跑完后打开output/output_01.avi,应该能看到画面里每个人身边有绿色边界框,顶部实时显示Up和Down的计数数字。如果输出视频是空的或者编码不兼容,先看第 5 章的避坑记录,多半是编码器四字符代码的问题。

4.2 切换 YOLO 检测器与置信度策略

SSD 跑通了,再试试 YOLO 的效果。把--prototxt和--model参数替换成--yolo模式,或者直接修改脚本中的模型加载分支。项目里 YOLO 的加载逻辑通常长这样:

python people_counter.py \ --yolo yolo-coco \ --input example_02.mp4 \ --output output/output_02.avi \ --confidence 0.5 \ --skip-frames 30

--yolo参数指向yolo-coco目录,脚本内部会读取yolov3.cfg和coco.names并加载权重。注意 YOLO 的置信度我建议设得比 SSD 高一些,因为 YOLOv3 对小目标的召回率不如 SSD 平滑,低置信度会带来大量误检框,反而干扰质心跟踪。

从实际效果看,SSD 在 CPU 上能跑到 10~15 FPS(取决于视频分辨率和机器性能),YOLO 在 CPU 上基本只有 2~5 FPS,精度提升有限但耗时成倍增长。我的建议是:没有 GPU 就用 SSD,把--confidence调到 0.3~0.4,再用--skip-frames拉高帧率。

4.3 实时摄像头与本地视频输入的参数差异

把--input从视频路径换成0,就可以读取摄像头实时流:

python people_counter.py \ --prototxt mobilenet_ssd/MobileNetSSD_deploy.prototxt \ --model mobilenet_ssd/MobileNetSSD_deploy.caffemodel \ --input 0 \ --output output/camera_output.avi \ --confidence 0.5 \ --skip-frames 15

摄像头场景下--skip-frames要调小,因为摄像头帧率不稳定,隔 30 帧检测一次容易把快速走过的行人漏掉。我一般设 10~15。本地视频如果本身帧率就低,--skip-frames也要相应减小,否则目标在两帧之间的位移过大,质心跟踪会匹配失败。

另外一个容易忽略的参数是输入尺寸。people_counter.py内部会用cv2.resize把帧统一到 400 像素宽(某些版本是 500),如果你想保留原视频分辨率做输出,需要在写VideoWriter时把帧尺寸对齐,否则输出的视频是黑屏或花屏,这点在避坑章节详细说。

5. 常见问题与避坑:输出空白、计数错乱与性能翻车

跑通 demo 只是起点,换真实场景的视频时问题就来了。我把实际使用中遇到的高频问题整理成踩坑记录,每一条都按「现象 → 原因 → 解决」的顺序写。

5.1 输出视频文件打不开或全黑

现象:程序跑完不报错,但生成的output_01.avi用播放器打开是黑屏,或者提示编码格式不支持。

原因:OpenCV 的VideoWriter创建时指定的编码器与实际写入的帧格式不匹配。项目里默认用cv2.VideoWriter_fourcc(*"MJPG"),某些环境不支持 MJPG 编码,或者--output路径的目录不存在,导致写入失败但程序没有抛出异常。

解决:先确认输出目录存在,再手动指定编码器。我一般会改用 XVID 编码,兼容性更好:

# 在 people_counter.py 中修改 VideoWriter 初始化 # 将 fourcc 从 MJPG 替换为 XVID,同时确保输出目录存在 fourcc = cv2.VideoWriter_fourcc(*"XVID") writer = cv2.VideoWriter(args["output"], fourcc, fps, (W, H), True)

代码逻辑说明:这里的W和H是输入帧的实际宽高,必须和后续writer.write(frame)写入的帧尺寸完全一致。如果你在检测前把帧缩小了,写完缩小后的帧,视频就会因为尺寸变化产生花屏。解决思路是「检测用小图,画出结果后放大回原尺寸再写入」。

5.2 计数线附近目标来回走导致重复计数

现象:同一个人在计数线位置徘徊,Up和Down的数字交替增长,明明只有一个人,却统计出了四五次进出。

原因:方向判定只看前后两帧质心的相对位置,目标在线附近来回抖动时,质心可能在计数线上下反复穿越。trackableobject.py里的counted字段本来是为了防止重复计数,但实现上有些版本只有在对象direction从未被赋值时才允许计数,一旦方向被赋过值就不再更新,所以问题出在方向被子节点反复翻转。

解决:连续多帧确认方向再计数,而不是单帧穿越就立刻判定。常见做法是给每个TrackableObject增加一个cross_count计数,方向上连续两帧穿越同一侧才真正计数:

# 在方向判定处增加连续帧确认逻辑 # 只有连续两帧都满足穿越条件才确认方向 if prev_y < LINE_Y and cur_y >= LINE_Y: obj.cross_count += 1 if obj.cross_count >= 2 and not obj.counted: obj.counted = True total_down += 1 elif prev_y >= LINE_Y and cur_y < LINE_Y: obj.cross_count += 1 if obj.cross_count >= 2 and not obj.counted: obj.counted = True total_up += 1 else: obj.cross_count = 0 # 方向不稳定时重置累加

代码逻辑说明:LINE_Y是自定义计数线 y 坐标,prev_y和cur_y分别是前后两帧的质心 y。只有当目标连续两帧都往同一方向穿越计数线时才确认计数并锁定counted,否则重置累加。这样能显著减少抖动带来的误计。

5.3 SSD 检测不到画面上方远处的小目标

现象:视频上半部分的人经常被漏检,计数结果明显比人工数少 20% 以上。

原因:SSD 的输入分辨率固定,远处的人占据的像素面积小,检测框置信度低,被--confidence 0.4的阈值过滤掉了。

解决:先降置信度到 0.2~0.3 观察效果,如果仍然漏检,考虑缩小计数区域——把计数线移到检测效果好的中近景区域,或者直接对画面下半部分做检测。另一种做法是提高检测输入尺寸,把people_counter.py里 resize 的目标宽度从 400 改成 600 甚至 800,代价是检测耗时明显上升。

5.4 CPU 跑 YOLO 帧率只有个位数,视频卡成幻灯片

现象:用 YOLO 检测器跑 1080p 视频,程序输出帧率只有 3~5 FPS,输出视频像慢放。

原因:YOLOv3 对 CPU 的算力要求远超 MobileNet SSD,这是模型本身的特性,不是代码问题。

解决:先用--skip-frames 10观察,如果帧率不足,把--confidence提到 0.6 减少候选框数量,再把输入尺度调小。终极方案是换回 SSD,或者把 YOLO 换成轻量版如 Tiny-YOLO。不要指望纯调参让 YOLO 在 CPU 上跑实时,这是模型训练侧决定的,不是后处理能挽救的。

5.5 模型文件路径报错或者读取不到

现象:运行时报错提示找不到MobileNetSSD_deploy.caffemodel,或者coco.names读取失败。

原因:相对路径和实际工作目录不一致。很多人直接在 IDE 里运行脚本,工作目录是项目根目录之外的地方,导致相对路径失效。

解决:用绝对路径,或者在脚本开头基于脚本所在目录定位文件路径:

# 用脚本目录拼接绝对路径,避免工作目录不一致导致加载失败 # 假设 people_counter.py 在项目根目录 import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) prototxt = os.path.join(BASE_DIR, "mobilenet_ssd", "MobileNetSSD_deploy.prototxt") model = os.path.join(BASE_DIR, "mobilenet_ssd", "MobileNetSSD_deploy.caffemodel")

代码逻辑说明:os.path.abspath(__file__)拿到当前脚本的绝对路径,再用os.path.join拼接模型文件路径。这样不管终端在哪个目录下执行命令,都能正确加载模型。

6. 进阶验证:用 demo 输出校准计数线与统计精度

项目里自带demo_output_01.gif和example_01.mp4,这两份资料不只是给你看效果的,还是校准计数精度的基准。先把example_01.mp4跑一遍,把输出视频和demo_output_01.gif逐帧对照——重点看人员穿越计数线的计数时机是否一致,如果 gif 里某个方向先 +1,你的输出里却延后了 N 帧才 +1,说明方向判定逻辑里的cross_count累加和 demo 版本有偏差。

自定义计数线是改造成本最低、收益最高的一个操作。假设你的监控画面里入口在底部、出口在顶部,把计数线从画面中间改到偏下位置,就能让行人一进入画面就完成方向判定;入口在顶部则往上移。具体操作是找到people_counter.py里H // 2的赋值,替换成固定值,同时画一条线方便肉眼校验:

# 自定义计数线位置示例:设置在第 300 行像素处 # 并绘制红色横线用于可视化确认 LINE_Y = 300 cv2.line(frame, (0, LINE_Y), (frame.shape[1], LINE_Y), (0, 0, 255), 2)

我自己的习惯是先用三个不同场景的视频分别校准计数线位置和--skip-frames值,记录每组参数的计数误差率。有一次在门店现场测试时发现,SSD 置信度调到 0.5 后傍晚逆光场景漏检严重,降到 0.3 后噪声变多但总误差反而更小——这套项目里的参数没有通用最优解,只有针对具体场景的局部最优。

验证完计数精度,还可以把计数结果定时写到 CSV 文件里做后续分析,这也是这个项目最有实战价值的扩展方向。核心是每一帧或者每 N 帧把当前累计值追加到文件末尾,注意用追加模式而不是覆盖模式,防止程序中断丢数据:

# 每隔 30 帧把计数结果追加到 CSV # 后续可以用 pandas 或 Excel 直接做客流趋势分析 import csv frame_count = 0 with open("count_log.csv", "a", newline="") as f: writer = csv.writer(f) if frame_count % 30 == 0: writer.writerow([frame_count, total_up, total_down]) frame_count += 1

代码逻辑说明:newline=""防止 Windows 下 CSV 写入出现空行,frame_count % 30控制采样频率,避免每秒写入几十条冗余数据。这样积累一天的数据,就能画出分时段的进出店客流曲线,比单纯看视频数字直观得多。

从那以后我每次拿到新的计数项目,都会强制走一遍「先跑 demo 对照 gif、再改计数线位置、最后记录参数与误差率」的流程,不到半小时就能判断这份代码在目标场景下值不值得往下投入。希望这份拆解能帮你少走我踩过的弯路,把上下行统计真正用起来。

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

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

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

立即咨询