首页 / 资讯中心 / 文章详情

基于Ubuntu与OpenCV的免费客流统计系统实现

基于Ubuntu与OpenCV的免费客流统计系统实现 ★ FEATURED ARTICLE
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-python4.8.1.78 opencv-contrib-python4.8.1.78 numpy提示如果pip下载速度慢可以用国内镜像源实测速度快很多。命令是pip install -i https://pypi.tuna.tsinghua.edu.cn/simple opencv-python4.8.1.78。安装完了验证一下能否正常调用摄像头python3 -c import cv2; cap cv2.VideoCapture(0); print(cap.isOpened())输出True就说明摄像头能正常工作。3.3 模型权重文件获取这一步是新手最容易卡住的地方。YOLOv4-tiny的权重文件和配置文件需要单独下载不在OpenCV的安装包里。我整理一下要用的三个文件yolov4-tiny.weights模型权重约23MByolov4-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, confidence0.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), swapRBTrue, cropFalse ) 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)是把输入图像统一缩放到指定尺寸这是模型要求的输入分辨率。swapRBTrue是因为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_distance50, max_frames30): 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, :], axis2 ) rows D.min(axis1).argsort() cols D.argmin(axis1) 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_distance50表示两帧之间同一个人的质心移动超过50像素就不算同一人。这个阈值怎么定取决于摄像头分辨率和人的移动速度。720P画面下正常人步行的速度也就每帧移动10到30像素50像素的容错已经比较宽松。如果摄像头装得高、画面范围大可以适当放宽到70。max_frames30的意思是一条轨迹连续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_pathmodels/yolov4-tiny.cfg, weights_pathmodels/yolov4-tiny.weights, names_pathmodels/coco.names, confidence0.4 ) tracker CentroidTracker(max_distance50, max_frames30) 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, fin: {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 为什么不用现成的多目标追踪APIOpenCV从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的Restartalways加上RuntimeMaxSec7200秒来强制周期重启。还有摄像头偶发断流的问题。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上搭一套照着上面的步骤走一遍大概率半天时间就能跑起来。
阅读完成 · 觉得有帮助?
咨询建站