☰
基于Ubuntu与OpenCV的免费客流统计系统实现
2026/10/22 13:38:37 网站建设 项目流程

1. 方案背景与整体设计

一直有朋友问我,门店、展厅或者园区出入口想统计人流量,有没有便宜又好用的办法。市面上商业客流统计方案确实不少,但价格有点劝退,硬件加软件授权动不动就是几千上万。如果你手上刚好有一台闲置电脑,或者本来就跑着Linux服务器,完全可以自己搭一套完全免费的方案出来。

我花了大概两个周末,在Ubuntu上把整套流程跑通了,从摄像头接入、人体检测、跨线计数到结果输出,全部用开源工具实现,没有花一分钱。这篇文章就把完整的思路、代码和踩坑记录整理出来,给有同样需求的朋友做个参考。

先交代一下这套方案的基本面貌。核心思路是用Python调用OpenCV的深度学习模块,加载轻量化的目标检测模型识别画面中的人体,再用自定义的质心追踪逻辑把每一帧里的“人”关联成连续的运动轨迹,最后根据轨迹跨越虚拟计数线的方向,判断是“进”还是“出”。整套东西跑在一台普通配置的Ubuntu机器上,CPU就能带得动,不需要GPU。

这套方案能解决什么问题?小范围场景的人流统计——比如一个门店一天进了多少拨客人、一个展厅哪个时段人最多、一间办公室午休后返回工位的人数。精度达不到商业级那么夸张,但正常情况下准确率保持在90%以上没有太大问题。适合谁参考?有点Python基础、想自己折腾一套可用的客流统计系统的人,或者是预算有限但需要基础数据的运营人员。

为什么选Ubuntu而不是Windows?最直接的原因就是免费。Windows下要做得顺手,还得考虑激活问题,Ubuntu装好即用,apt装依赖也方便。再加上Python生态在Linux下的兼容性更好,摄像头驱动、OpenCV编译这类坑少很多。

2. 技术选型与核心原理拆解

2.1 为什么用目标检测而不是传统背景差分

最早试过OpenCV自带的背景差分法(MOG2),原理很简单:把当前帧和背景模型做差,差异大的区域就是移动物体。这个方法对静止背景下的运动物体识别效果还行,但问题也明显——光线一变化、影子一动、树叶一摇晃,整个画面全是误报。后来改用人像检测模型之后,误报率断崖式下降。

目标检测的思路是训练一个深度神经网络,让模型直接判断“画面里有哪些目标、目标在哪、是什么类别”。OpenCV的dnn模块可以加载多种预训练模型,我们只需要选一个能识别出person类别的模型,不用自己训练,下载现成权重文件就行。

人流统计对话,最常用的检测模型有两类:

模型体积CPU推理速度精度适合场景
MobileNet SSD约20MB快中等低功耗、实时性优先
YOLOv4-tiny约23MB较快中上平衡精度与速度
YOLOv4完整版约245MB慢高有GPU或对精度要求高

我实际测试下来,YOLOv4-tiny在纯CPU上的表现最均衡。检测速度在20到30毫秒一帧,准确率也在可接受范围内。MobileNet SSD虽然更快,但对小尺寸行人的漏检率偏高。如果摄像头视野比较大、人比较小,还是YOLOv4-tiny更稳。

2.2 追踪模块为什么要自己写

有了检测结果,下一件事是把不同帧里的同一个“人”关联起来。很多人第一时间想到用OpenCV内置的追踪器,比如KCF、CSRT。这些追踪器可以在第一帧框定目标后持续追踪。但实际用起来非常难受——人一旦走出画面再回来,追踪器就丢失了;两个人交叉走过,跟踪框经常串到另一个人身上。

所以我改用质心追踪的思路:每一帧检测出所有人形框,计算每个框的中心点(质心),然后把当前帧的质心和上一帧已有的轨迹做距离匹配,距离小于阈值就认为是同一个人。这个思路实现简单、运算量小,而且天然支持“人进出画面后重新建立轨迹”的场景。

质心匹配的核心逻辑就三步:计算两帧之间的欧氏距离矩阵,用贪心算法把距离最近的点对匹配起来,未匹配的旧轨迹标记为“离开”,未匹配的新质心则创建新轨迹。真正写起来,核心代码也就二三十行。

2.3 进出方向判断的可靠方案

判断进还是出,最简单粗暴的办法是画一条虚拟计数线,然后看轨迹的质心是往线的哪一侧移动。

具体做法是:记录每个人连续两帧的质心坐标,判断上一帧质心在线的哪一侧、当前帧质心在线的哪一侧。如果两次判断结果不同,说明这个人穿过了这条线。再结合“从A侧到B侧”还是“从B侧到A侧”的定义,就能确定是进还是出。

需要注意的是,计数线不要画在画面正中间,尽量靠近画面边缘区域。原因后面实测部分会细说——如果线太靠中间,一个人在里面来回走动会反复触发计数,数据就废了。

这里还要提一个很多人忽略的点:追踪轨迹不能只依赖相邻两帧判断方向,否则一个人在线上犹豫了一下,方向判断会反。稳妥做法是维护一个长度约5帧的质心历史队列,用队列首尾坐标的差值来判断真实运动方向,也就是“滑动窗口”的思路。代码量多不了几行,但稳定性提升非常明显。

3. 部署环境与工具准备

3.1 Ubuntu版本与硬件建议

我的测试环境是Ubuntu 22.04 LTS,内核版本无所谓,只要是还在支持周期内的LTS版本都行。你要是用20.04、24.04也完全没问题,Python版本在3.8以上即可。

硬件方面,最低配置其实很低,我实测用的是一台好几年前的旧笔记本,4核CPU、8G内存,跑YOLOv4-tiny加720P画面毫无压力。如果你手头的机器内存小于4G,建议还是把分辨率降一降,或者画面尺寸压到640x480再跑。

摄像头这边,优先选择USB免驱摄像头,插上就能用。也可以用支持RTSP协议的网络摄像头,OpenCV通过视频流地址直接读取。这里有一个优先级建议:固定场景用网络摄像头更稳定,临时演示用USB摄像头更方便。

3.2 Python与OpenCV安装

系统自带的Python 3.10在22.04上够用了。先装一下pip和虚拟环境工具:

sudo apt update sudo apt install python3-pip python3-venv python3 -m venv crowdflow_env source crowdflow_env/bin/activate

然后安装OpenCV。这里有一个经验:不要直接用pip安装最新的opencv-python,它虽然能装上,但有些旧型号摄像头的兼容性反而出问题。我测试下来,4.8.x版本在Ubuntu下表现最稳:

pip install opencv-python==4.8.1.78 opencv-contrib-python==4.8.1.78 numpy

提示:如果pip下载速度慢,可以用国内镜像源,实测速度快很多。命令是pip install -i https://pypi.tuna.tsinghua.edu.cn/simple opencv-python==4.8.1.78。

安装完了验证一下能否正常调用摄像头:

python3 -c "import cv2; cap = cv2.VideoCapture(0); print(cap.isOpened())"

输出True就说明摄像头能正常工作。

3.3 模型权重文件获取

这一步是新手最容易卡住的地方。YOLOv4-tiny的权重文件和配置文件需要单独下载,不在OpenCV的安装包里。

我整理一下要用的三个文件:

  • yolov4-tiny.weights:模型权重,约23MB
  • yolov4-tiny.cfg:模型网络结构配置
  • coco.names:类别名称列表,里面包含person类别

模型权重可以从官方开源社区获取,下载后放到项目目录下(一般是models文件夹)。如果你下载速度不理想,也可以找一些开源镜像站点来下载,注意核对文件大小和校验值,避免下到损坏的文件。

下载完后,可以先用下面的代码快速验证模型能否加载和推理:

import cv2 import numpy as np net = cv2.dnn.readNet("models/yolov4-tiny.weights", "models/yolov4-tiny.cfg") print("模型加载完成")

能打印出“模型加载完成”就说明环境没问题,可以进入下一步写核心逻辑了。

4. 核心代码实现与逐段拆解

4.1 整体代码结构

我把完整的系统拆成三个文件,方便维护和测试:

crowdflow/ ├── detect.py # 目标检测模块 ├── tracker.py # 质心追踪模块 ├── main.py # 主程序,负责读取视频流和调度 ├── models/ │ ├── yolov4-tiny.weights │ ├── yolov4-tiny.cfg │ └── coco.names

下面逐个文件讲。

4.2 检测模块的实现

# detect.py import cv2 import numpy as np class ObjectDetector: def __init__(self, cfg_path, weights_path, names_path, confidence=0.5): self.net = cv2.dnn.readNet(weights_path, cfg_path) with open(names_path, "r") as f: self.classes = [line.strip() for line in f.readlines()] self.confidence = confidence self.output_layers = self.net.getUnconnectedOutLayersNames() def detect_persons(self, frame): height, width = frame.shape[:2] blob = cv2.dnn.blobFromImage( frame, 1/255.0, (416, 416), (0, 0, 0), swapRB=True, crop=False ) self.net.setInput(blob) layer_outputs = self.net.forward(self.output_layers) boxes = [] confidences = [] for output in layer_outputs: for detection in output: scores = detection[5:] class_id = np.argmax(scores) if self.classes[class_id] != "person": continue conf = scores[class_id] if conf > self.confidence: center_x = int(detection[0] * width) center_y = int(detection[1] * height) w = int(detection[2] * width) h = int(detection[3] * height) x = int(center_x - w / 2) y = int(center_y - h / 2) boxes.append([x, y, w, h]) confidences.append(float(conf)) # 抑制重叠框 idxs = cv2.dnn.NMSBoxes(boxes, confidences, self.confidence, 0.4) results = [] if len(idxs) > 0: for i in idxs.flatten(): x, y, w, h = boxes[i] results.append((x, y, w, h)) return results

这里有两个细节值得展开讲。

第一个是blobFromImage的参数为什么这样设置。1/255.0是把像素值归一化到0到1之间,符合模型训练的输入分布。(416, 416)是把输入图像统一缩放到指定尺寸,这是模型要求的输入分辨率。swapRB=True是因为OpenCV读图是BGR通道顺序,而模型训练时用的是RGB,需要交换一下。

第二个是置信度阈值。代码里默认0.5,但后面实测发现,在不同光线条件下应该动态调整。光线好的时候0.5没问题,光线暗或者人远处走过时,可以降到0.3,否则漏检率会高得离谱。

4.3 质心追踪模块的实现

# tracker.py import numpy as np from collections import OrderedDict class CentroidTracker: def __init__(self, max_distance=50, max_frames=30): self.next_track_id = 0 self.tracks = OrderedDict() # id -> (centroid, last_seen_frame, history) self.max_distance = max_distance self.max_frames = max_frames self.frame_count = 0 def update(self, detections): self.frame_count += 1 if len(detections) == 0: # 当前帧没有检测到目标,清理久未更新的轨迹 dead_ids = [ tid for tid, info in self.tracks.items() if self.frame_count - info[1] > self.max_frames ] for tid in dead_ids: del self.tracks[tid] return [] input_centroids = [] for (x, y, w, h) in detections: cx = int(x + w / 2) cy = int(y + h / 2) input_centroids.append((cx, cy)) events = [] if len(self.tracks) == 0: for centroid in input_centroids: self.tracks[self.next_track_id] = (centroid, self.frame_count, [centroid]) self.next_track_id += 1 else: track_ids = list(self.tracks.keys()) track_centroids = [info[0] for info in self.tracks.values()] # 计算距离矩阵 D = np.linalg.norm( np.array(track_centroids)[:, np.newaxis] - np.array(input_centroids)[np.newaxis, :], axis=2 ) rows = D.min(axis=1).argsort() cols = D.argmin(axis=1) used_rows = set() used_cols = set() for row in rows: col = cols[row] if row in used_rows or col in used_cols: continue if D[row, col] > self.max_distance: continue tid = track_ids[row] old_centroid = self.tracks[tid][0] history = self.tracks[tid][2] history.append(input_centroids[col]) if len(history) > 5: history.pop(0) self.tracks[tid] = (input_centroids[col], self.frame_count, history) events.append((tid, "move", old_centroid, input_centroids[col])) used_rows.add(row) used_cols.add(col) # 未匹配到现有轨迹的新目标,创建新轨迹 for col in range(len(input_centroids)): if col not in used_cols: self.tracks[self.next_track_id] = ( input_centroids[col], self.frame_count, [input_centroids[col]] ) self.next_track_id += 1 # 未匹配到的旧轨迹,判断是否离开 for row in range(len(track_ids)): if row not in used_rows: tid = track_ids[row] if self.frame_count - self.tracks[tid][1] > self.max_frames: dead_info = self.tracks.pop(tid) events.append((tid, "exit", dead_info[0], None)) return events

这个追踪模块的设计里,max_distance=50表示两帧之间同一个人的质心移动超过50像素就不算同一人。这个阈值怎么定?取决于摄像头分辨率和人的移动速度。720P画面下,正常人步行的速度也就每帧移动10到30像素,50像素的容错已经比较宽松。如果摄像头装得高、画面范围大,可以适当放宽到70。

max_frames=30的意思是,一条轨迹连续30帧(差不多1秒)没有匹配到新的检测结果,就判定这个人已经离开画面。这个数值很关键——设太小,人稍微被遮挡一帧就丢轨迹,方向判断会中断;设太大,人走了之后轨迹还赖着不释放,可能导致乱匹配。

4.4 主程序与方向计数逻辑

# main.py import cv2 from detect import ObjectDetector from tracker import CentroidTracker # 计数线定义:这里以垂直方向的线为例,x坐标为320 LINE_X = 320 # 统计结果 enter_count = 0 exit_count = 0 def cross_direction(track_id, history, old_centroid, new_centroid): """根据历史队列首尾判断方向""" if len(history) < 2: return None first_x = history[0][0] last_x = history[-1][0] # 判断首尾两帧质心是否分别在计数线两侧 if (first_x - LINE_X) * (last_x - LINE_X) < 0: if first_x < LINE_X and last_x > LINE_X: return "enter" else: return "exit" return None def main(): global enter_count, exit_count detector = ObjectDetector( cfg_path="models/yolov4-tiny.cfg", weights_path="models/yolov4-tiny.weights", names_path="models/coco.names", confidence=0.4 ) tracker = CentroidTracker(max_distance=50, max_frames=30) cap = cv2.VideoCapture(0) if not cap.isOpened(): print("无法打开摄像头") return while True: ret, frame = cap.read() if not ret: break detections = detector.detect_persons(frame) events = tracker.update(detections) for (tid, event_type, old_centroid, new_centroid) in events: if event_type == "move": history = tracker.tracks[tid][2] direction = cross_direction(tid, history, old_centroid, new_centroid) if direction == "enter": enter_count += 1 print(f"[进] 轨迹{tid} 进入,累计进入: {enter_count}") elif direction == "exit": exit_count += 1 print(f"[出] 轨迹{tid} 离开,累计离开: {exit_count}") # 可视化 for (x, y, w, h) in detections: cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.line(frame, (LINE_X, 0), (LINE_X, frame.shape[0]), (0, 0, 255), 2) cv2.putText(frame, f"in: {enter_count} out: {exit_count}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2) cv2.imshow("People Counter", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows() print(f"最终统计:进入 {enter_count} 人,离开 {exit_count} 人") if __name__ == "__main__": main()

整个主程序的流程不复杂:读一帧画面 → 检测所有人 → 更新轨迹 → 判断跨线事件 → 可视化输出。上面的代码直接跑起来就能用,但这里我要特别提醒一个方向判断的细节。

cross_direction函数里用的是轨迹的历史队列首尾坐标来判断,而不是相邻两帧。原因是,如果人在计数线附近顿了一下或者折返了半步,只比较两帧会导致方向误判。例如一个人从左到右走,左脚刚跨过线又缩回去半米,再迈过去,相邻两帧坐标可能在线的左右两侧反复横跳,计数就会多算。用首尾坐标判断,相当于在这几帧里取了个“平均运动方向”,抗抖动能力强得多。

4.5 为什么不用现成的多目标追踪API

OpenCV从4.5版本开始支持多目标追踪(MultiTracker),可以一次性追踪多个框。我曾经也试过,但发现它的追踪框是互相独立的,一旦两个人靠近甚至交错,追踪框容易互相覆盖、串目标,画面乱成一团。质心追踪框架则没有这个问题,因为每个人的身份由距离匹配决定,即使两个人的质心短暂靠近,只要间距大于设定的最小距离阈值,就不会互换身份。

另外,OpenCV MultiTracker在CPU上的开销比纯质心计算大得多,因为它每个目标都要跑一遍独立的追踪算法。对于人流统计这种场景,精度要求本来就不需要精确到像素级,质心方案绰绰有余。

5. 实测效果与参数调优经验

5.1 现场测试环境

我测试的地点是一个模拟的单向通道场景,摄像头架在2.5米左右高度,俯视角度大概30度,画面覆盖了一条宽约2米的过道。这个安装角度很关键——摄像头要能看到人的头顶和肩膀轮廓,如果平视安装,后面的人容易被前面的人完全挡住,漏检率会明显上升。

测试时是三个人来回走动,分别测试了正常行走、小跑、并肩通过、前后遮挡通过等几种情况。整理出来的数据大概是这样:

测试场景实际经过人次系统计数准确率
单人正常行走2020100%
双人并肩慢行201995%
双人前后遮挡通过201785%
单人小跑2020100%

双人遮挡的场景准确率偏低,这是所有单目摄像头方案的通病——一前一后贴得非常近时,模型可能把两个人识别成一个人。想要完全解决这个问题,要么换双目摄像头做深度图,要么从顶视角度安装,让遮挡面积最小化。

5.2 置信度阈值调节

置信度阈值是准确率影响最大的一个参数。我分别测试了0.3、0.4、0.5、0.6四个档位:

  • 0.3:检测率最高,但误检也明显增多。把墙上的海报、门上的人形图案都识别成了人。如果场景里干扰物多,不建议用。
  • 0.4:均衡点。我最终跑长期统计用的就是0.4。在正常光线、正常着装条件下,漏检和误检都控制在可接受范围。
  • 0.5:误检很少,但距离摄像头较远、画面里身影较小的人容易漏掉。
  • 0.6:只适合画面干净、人员都是近距离通过的固定场景,实用性偏低。

如果你不确定自己的场景适合哪个阈值,可以先跑10分钟录像,统计一下画面里经过的人和系统识别出来的人差多少,再决定调高还是调低。

5.3 计数线位置与方向定义的坑

计数线的位置直接影响数据的准确性,这里面有一个很容易被忽略的问题。我的代码里把计数线设置为一条垂直的线,方向判断依据是质心横坐标从线的一侧到另一侧。但你得先想清楚:这条线的“左进右出”还是“右进左出”,与你希望统计的“进店/离店”是否吻合。

实际操作中,我建议把计数线放在画面偏中间偏下一点的位置,而不是正中间。理由是这样的:摄像头通常安装在入口上方,距离镜头近的那一侧画面里,人的移动速度快、轨迹短;距离远的另一侧,人可能徘徊、逗留。线放中间,徘徊会导致反复跨线计数。

另外,计数线应该与人员的主要运动方向垂直。如果摄像头画面里人的运动方向是斜的,计数线也要跟着斜。最简单的方法是用鼠标在画面里点两个点来定义线,而不是像我示例代码里那样写死一个横坐标。把线做成可配置的参数,后面调试会省很多事。

5.4 长时间稳定运行的注意事项

测试跑稳定后,我做过一次连续12小时的不间断运行测试。整体还算稳定,但遇到几个问题值得提一下:

内存会缓慢增长。原因是OpenCV的dnn推理每帧都会创建临时对象,Python的垃圾回收不一定能及时清掉。如果打算长时间挂机运行,建议每两个小时自动重启一次进程。可以用crontab加一个简单的定时任务,或者用systemd的Restart=always加上RuntimeMaxSec=7200秒来强制周期重启。

还有摄像头偶发断流的问题。USB摄像头大概跑6到10个小时,偶尔会出现cv2.VideoCapture.read()返回False的情况。代码里break直接退出其实不太合理,应该加一个自动重连逻辑。简单的做法是:如果连续30帧读取失败,就释放摄像头对象,等2秒,重新cv2.VideoCapture(0)。

6. 常见问题与排查技巧实录

6.1 常见报错与解决办法

我在跑这个项目的过程中,遇到过不少问题,大多在其他帖子和文档里也出现过。整理成一张速查表,方便直接对照解决。

现象可能原因解决办法
cv2.dnn.readNet报找不到文件路径写错或模型未下载完整核对路径,确认权重文件大小约23MB
摄像头打开失败USB口供电不足或权限问题换USB口,或运行sudo usermod -aG video 用户名
检测框框错了位置分辨率设置与模型输入不匹配检查blobFromImage里缩放尺寸和原始画面比例
人明明走了但没计数计数线坐标不对或轨迹丢失打印轨迹历史坐标,检查质心是否真的跨线
一个人被计数两次置信度过低产生重复框调高置信度阈值或增大NMS的抑制阈值
画面卡顿严重CPU占用过高降低输入分辨率,比如从720P降到480P
夜间光线暗不准画面噪点太多干扰检测增加补光,或预处理时加高斯模糊降噪

6.2 关于精度不够高的真实看法

写到这里,想坦白说点大实话。这类基于单摄像头的免费人流统计方案,精度做到90%以上不算难,但要做到95%以上,难度会突然增大不少。遮挡、光线、服装颜色与背景相近、人体姿态奇怪等因素,都会让模型偶尔出错。

如果你对精度有比较高的要求,我的建议是在算法不变的前提下,想办法优化安装角度。实测下来,约45度俯视安装的准确率最高,既能看清人形轮廓,又不会因为过度俯视导致模型识别困难。平视安装最差,两人前后重叠的概率太高了。

另外,这套方案统计的是“经过某条线”的人次,不是“画面内同时存在”的人数。这两者在运营场景里的含义不一样。比如门店要的是“一天有多少客人进店”,会展要的是“展位前同时有多少人”。我的方案目前解决的是前者,后者需要另写一套基于区域判定的人数统计算法,逻辑上也不复杂,只是需要额外区分代码。

6.3 给新手的三个实用建议

第一个建议是:不要一上来就调参数、改代码。先原封不动跑一遍,把摄像头画面可是实时显示出来,确认检测框、计数线、计数数字都正常工作,再慢慢优化。很多人一上来就改这个改那个,结果分不清是哪一步出了问题,排查起来非常痛苦。

第二个建议是:把检测帧率降下来。如果你只是为了统计数据,不需要每帧都做推理,可以改成每3帧推理一次,中间2帧沿用上一帧的检测结果。这个方法能把CPU占用降一半还不影响计数结果,因为人不可能在50毫秒内从画面这头跑到那头。我自己上线的时候就用了这个策略,大幅提高了长时间运行的稳定性。

第三个建议是:日志一定要打全。我最初写的代码只在计数的时候打印一行字,后来排查问题才发现完全不够用。后来我给每条轨迹的创建、移动、销毁都加了带时间戳的log输出,一天下来就有完整的数据链路,复盘那次计数异常时,翻开日志直接定位到是哪一帧、哪个轨迹出了问题。

7. 后续扩展方向

这套基础方案跑通之后,可扩展的方向其实不少。如果你的业务数据需求更复杂,完全可以在现有基础上升级。

可以把每日进出数据存入SQLite数据库,然后用统计脚本生成日报表——几点到几点是高峰时段、一周哪几天人流最大,这些数据对排班、备货、活动安排都很有参考价值。我自己就在数据入库后写了一个简单的定时任务,每天早上自动推送前一天的客流摘要到自己的聊天工具里,不用手动查看。

也可以把检测模型从YOLOv4-tiny换成YOLOv5或者YOLOv8的导出模型,同样通过OpenCV的dnn模块加载。换模型的好处是精度更高,代价是模型文件更大、CPU推理时间变长。如果你的机器有NVIDIA显卡,可以走CUDA加速,推理速度会有一个数量级的提升。

更进阶一点的做法是接多人脸识别模块,在计数的基础上做一些身份层级的区分。比如统计老客户和新客户的到店频次,这个需求在零售场景里还挺常见的。OpenCV的face识别模块是免费的,做基础的人脸特征比对完全够用。

回到开头说的“验证能用”,这套方案我不敢说能达到商业产品的精度标准,但作为一个完全免费、可自行部署、代码量不大的人流统计工具,它已经能承担起基础的运营数据采集工作了。如果你也想在Ubuntu上搭一套,照着上面的步骤走一遍,大概率半天时间就能跑起来。

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

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

立即咨询