简介这是一套基于Python与OpenCV的人流量计数及上下行方向统计项目资源适合有一定Python基础、希望掌握图像处理与目标跟踪的开发者可应用于零售门店、车站与公共场所的客流分析。压缩包共20个文件、大小约138.21MB包含5个Python源码文件、4个MP4测试视频、2个AVI输出视频以及MobileNetSSD的caffemodel与prototxt模型配置覆盖从背景建模、轮廓检测到卡尔曼滤波跟踪的完整流程。核心程序people_counter.py整合了背景减除、轮廓提取与质心跟踪算法亦提供初始对照版本便于比较配套的yolo-coco目录下包含YOLOv3的cfg与coco.names方便扩展检测方案。项目还附带README说明与demo演示动图可帮助理解上下行区域划分与双方向计数的实现细节。资源已有511人学习压缩包内视频与代码对应清晰适合借实际数据调试阈值并迁移到自己的监控场景中。1. 用 Python 做上下行人流量计数这套方案为什么值得自己搭一套商场客流、地铁闸机、园区出入口、连锁门店的进店率统计这些场景里最刚需的并不是「总共有多少人经过」而是「这一小时内上行下去了多少人、下行上来了多少人」。Python 做人流量计数落到代码层面通常是「目标检测 多目标跟踪 方向判定」三段式先框出画面里的人再给每个人分配一个稳定的 ID最后根据 ID 的轨迹穿越某条虚拟线时的坐标变化判断他到底是上行还是下行。这个方向最大的价值在于它是少数能在普通办公电脑上跑出实时效果、又不需要采购专用硬件的视觉项目。用 YOLOv8s 做检测配合 ByteTrack 做跟踪1080p 视频去掉渲染开销后在 RTX 3060 上能跑到 30 FPS 以上CPU 上也能用轻量模型蹲 5-10 FPS 的实时输出。适合的人群很明确有摄像头或录好的监控视频、想自己做客流分析但又不想被商业套装绑定、以及正在入门深度学习落地但不想只停留在跑通官方 Demo 的人。下面这套方案能把「画面里的框」变成「可量化、可对账的业务数据」。2. 先解决两个选型问题检测模型用哪家跟踪器用哪个2.1 检测模型YOLO 系依然是当前性价比最高的选择人流量计数的第一步是把画面里的每个人找出来。传统方案里的背景差分、HOG SVM 在固定摄像头、稀疏人流的简单场景还能用但一旦出现两个人重叠、光线突变、有人拖着行李箱蹲下系鞋带传统方案就会把一个人切成两段或者漏检一整帧。深度学习的检测模型里YOLO 系列因为推理速度快、生态成熟Ultralytics 库把训练、导出、推理全包了是目前做实时计数最省事的路线。我一般会选 YOLOv8s 作为默认起点。对比过 n/s/m/l 几个尺寸后s 版本在 COCO 预训练权重下对「人」这个类别的 mAP 和推理速度最均衡输入 640x640、批量 1 时在 3060 上单帧推理约 5-8 毫秒给跟踪和计数逻辑留出了充足预算。如果你的摄像头安装高度很低、人离镜头很近可以换 YOLOv8n 提速如果场景是地铁站台这种人流密度极高的俯视视角可以考虑 YOLOv8m 提升重叠目标的召回率。用 Ultralytics 加载预训练模型做检测只需要几十行代码from ultralytics import YOLO import cv2 # 加载 COCO 预训练权重只保留 person 这一类 model YOLO(yolov8s.pt) cap cv2.VideoCapture(input.mp4) fps cap.get(cv2.CAP_PROP_FPS) frame_width int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) frame_height int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) while cap.isOpened(): ret, frame cap.read() if not ret: break # 这里只做检测演示后续章节会接入跟踪器 results model(frame, classes[0], conf0.4, imgsz640, verboseFalse) boxes results[0].boxes if boxes is not None: for box in boxes: x1, y1, x2, y2 box.xyxy[0].tolist() score box.conf[0].item() cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.imshow(detection, frame) if cv2.waitKey(1) 0xFF ord(q): breakclasses[0]是这里最关键的参数COCO 数据集的 80 个类别里0对应 person过滤掉其他类别能减少误检、提升推理速度。conf0.4是个经验值室内固定摄像头可以放到 0.5室外光线变化大的场景降到 0.25——但调低阈值后跟踪阶段要用别的机制过滤抖动后面避坑章节会细说。2.2 跟踪器选型不是所有「追踪」都适合计数场景检测框只是单帧的静态结果计数需要知道「这一帧的框是上一帧的哪个人」。跟踪器负责干这件事。当前主流选择是 DeepSORT 和 ByteTrack两者区别直接影响你后续踩坑的深度。DeepSORT 是「检测 运动特征 外观特征」三重关联卡尔曼滤波预测位置匈牙利算法做匹配ReID 模型提取外观特征。适合行人外观差异明显的场景比如穿着不同颜色衣服的人交错走过但有两个痛点需要额外加载一个 ReID 模型推理耗时增加当两个人外观非常相似都穿黑外套时ID 很容易互相跳变。ByteTrack 是纯运动关联它的思路是「不放过低分检测框」每一帧把高置信度框和低置信度框都纳入关联流程先用高置信度框做第一轮匹配再用低置信度框补漏。因为不依赖外观特征速度快、在小目标密集场景里表现更稳定。人流量计数的核心误报来源是「一个人被重复计数」而重复计数的根源是「同一个人的 ID 中途丢失后又被当成新目标」ByteTrack 的低分框补偿机制恰恰在抑制这类问题。from boxmot import ByteTrack # 初始化跟踪器帧率 25、最小轨迹长度 3、低置信度阈值 0.1 tracker ByteTrack( track_thresh0.4, # 高置信度框阈值高于此值参与第一轮匹配 match_thresh0.8, # IOU 匹配阈值低于此值视为新目标 min_frames3, # 连续出现 3 帧才确认 ID避免单帧误检 frame_ratefps # 用于卡尔曼滤波中最大匹配距离的时间补偿 ) for result in results: boxes result.boxes.xyxy.cpu().numpy() scores result.boxes.conf.cpu().numpy() # 输入跟踪器的格式Nx5前四列是框坐标第五列是置信度 tracks tracker.update(boxes, scores, frame)min_frames3是计数场景的保命参数没有它一个只在画面里出现 2 帧的噪点也会瞬间拿到一个 ID如果它恰好穿过了计数线就会产生一个假计数。match_thresh调大比如 0.9会让同一目标的关联更严格、ID 更容易断裂调小比如 0.7则会把距离很近的不同人合并成一个人。固定摄像头场景我从 0.8 起步先跑 10 分钟视频看 ID 切换频率再微调。2.3 数据流设计先想清楚「帧从哪来、结果往哪去」选定了检测器和跟踪器后整个计数程序的骨架就是一个生产者-消费者结构。生产者是摄像头或视频文件中间环节是「检测 - 跟踪 - 判断是否穿越虚拟线」消费者是计数结果需要落到三处终端日志、CSV 文件用于事后复盘、Redis 或数据库用于实时看板。import time import csv from collections import defaultdict from datetime import datetime up_count 0 down_count 0 # 记录每个人上次的位置用于判断连续帧间的位移方向 prev_positions {} # 记录每个人是否已经完成计数同一个 ID 只统计一次 counted_ids set() csv_file open(counts.csv, w, newline) writer csv.writer(csv_file) writer.writerow([timestamp, direction, total_up, total_down])数据流里最容易翻车的点是「帧率假设」。如果你的视频文件是 30 FPS 但实际采集设备是 15 FPS跟踪器内部的时间补偿参数会错乱表现为 ID 频繁断裂。所以frame_rate这个参数一定要从视频元数据里读不要写死。3. 上下行计数怎么做虚拟线法从原理到实现3.1 方向判定逻辑用轨迹穿线而不是用单帧位移上下行计数的核心不是「检测人在哪」而是「人往哪个方向走」。最常见的可靠做法是虚拟线法在画面里画一条线当某个跟踪 ID 的轨迹与这条线相交时根据相交前后目标中心点的坐标变化方向来判定是上行还是下行。为什么不直接比较相邻两帧的坐标差因为检测框有抖动一个人的中心点可能在两帧之间先向右摆、再向左摆单帧方向的信噪比太低。正确做法是记录同一个 ID 的轨迹取穿越前后两个关键点的坐标来算方向把几十帧的随机抖动平均掉。虚拟线的位置选择也有讲究优先选在人流方向稳定、没有回头路的通道上比如楼梯口、闸机内侧、走廊中段。不要在画面边缘画线因为目标进画面和出画面的那一瞬间检测框最不稳定容易出「假穿越」。3.2 实现一个不含跟踪器的纯方向判定 Demo先不接跟踪器用 OpenCV 的cv2.TrackerKCF或直接给检测框加「最近邻匹配」来理解穿线逻辑。下面这个 Demo 演示了最核心的方向判定逻辑用了最简单的最近邻匹配代替正式跟踪器import cv2 import numpy as np from collections import deque # 虚拟线从 (x1, y1) 到 (x2, y2)线下方为 A 侧上方为 B 侧 line_start (0, 300) line_end (640, 300) # 用队列保存每个 ID 最近 10 帧的中心点轨迹 trajectories defaultdict(lambda: deque(maxlen10)) prev_boxes {} # 上一帧的检测框用于最近邻匹配 def point_side(p, line_start, line_end): 利用向量叉积判断点在线的哪一侧 x, y p x1, y1 line_start x2, y2 line_end cross (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) return 1 if cross 0 else -1 # 1 为下方-1 为上方 def match_boxes(cur_boxes, prev_boxes, iou_thresh0.3): 用 IOU 最近邻给当前帧的框分配上一帧的 ID matches {} used_prev set() for i, cur in enumerate(cur_boxes): best_iou, best_id iou_thresh, None for pid, prev in prev_boxes.items(): if pid in used_prev: continue iou compute_iou(cur, prev) if iou best_iou: best_iou iou best_id pid if best_id is not None: matches[i] best_id used_prev.add(best_id) return matches # 每帧处理流程 def process_frame(frame, frame_idx): global up_count, down_count # 这里替换为你自己的检测器输出 cur_boxes (Nx4) # cur_boxes model(frame, classes[0])[0].boxes.xyxy.cpu().numpy() matched_ids {} for i, box in enumerate(cur_boxes): cx (box[0] box[2]) / 2 cy (box[1] box[3]) / 2 if i in matches: pid matches[i] else: pid new_id() trajectories[pid].append((cx, cy)) # 只有轨迹长度 2 时才判断穿越 if len(trajectories[pid]) 2: prev_p trajectories[pid][-2] curr_p trajectories[pid][-1] prev_side point_side(prev_p, line_start, line_end) curr_side point_side(curr_p, line_start, line_end) if prev_side ! curr_side: # prev_side1下方- curr_side-1上方定义为上行 if prev_side 1 and curr_side -1: up_count 1 counted_ids.add(pid) elif prev_side -1 and curr_side 1: down_count 1 counted_ids.add(pid) matched_ids[i] pid prev_boxes matched_ids逻辑说明point_side用向量叉积算点在线段的哪一侧这是穿线判断的标准做法比用横纵坐标比较更通用——虚拟线不一定是水平线也可以是斜线甚至折线。轨迹队列保留最近 10 帧因为穿越动作通常发生在连续 3-5 帧内留 10 帧足够覆盖同时避免轨迹太长导致方向判断滞后。这个 Demo 里没有实现compute_iou和new_id它们在正式实现里会用到 NumPy 的矢量运算和自增计数器。用这个 Demo 的目的是让你先跑通「方向判定」本身把虚拟线画好用鼠标模拟几个人从线下走到线上观察上下行计数是否正确。3.3 接入 ByteTrack把 Demo 换成正式跟踪器的三个改动点Demo 里的最近邻匹配在多人交叉时会失败——两个人交错时最近邻会把 B 的框匹配成 A 的延续方向判断就错了。换成 ByteTrack 只需要改一个接口调用但有三处逻辑要跟着调整。第一处跟踪器输出的tracks已经是带 ID 的规范化坐标不需要自己维护prev_boxes匹配字典。第二处ByteTrack 输出里-1表示该帧没有有效框需要跳过。第三处也是很多人忽略的ByteTrack 会把「中途丢失超过 N 帧又重新出现」的目标分配新 ID这会导致同一个人的轨迹断成两截如果恰好断在虚拟线附近就会出现「前半段穿线判定了一次方向后半段又判定了一次」产生重复计数。from boxmot import ByteTrack tracker ByteTrack(track_thresh0.4, match_thresh0.8, min_frames3, frame_ratefps) for frame_idx, frame in enumerate(frames): # 检测 results model(frame, classes[0], conf0.4, imgsz640, verboseFalse) boxes results[0].boxes.xyxy.cpu().numpy() scores results[0].boxes.conf.cpu().numpy() # 跟踪 tracks tracker.update(boxes, scores, frame) # tracks: (N, 6)第 6 列是 ID for track in tracks: x1, y1, x2, y2, track_id track[:5].astype(int) cx, cy (x1 x2) / 2, (y1 y2) / 2 trajectories[track_id].append((cx, cy)) # 轨迹超过 10 帧后只保留最近的部分防止穿线判断延迟 if len(trajectories[track_id]) 10: trajectories[track_id].popleft()tracks数组的列顺序是[x1, y1, x2, y2, conf, id]不同版本的 BoxMOT 库可能把 ID 放在最后一列接入时先print(tracks.shape, tracks[0])确认一次再写死索引。轨迹队列用deque(maxlen10)后就不需要手动popleft()但有的读者习惯用普通列表那就必须在追加时主动裁剪否则内存会随运行时长线性增长。3.4 区域计数法和虚拟线法怎么选虚拟线法适合「单向通道、人必须穿过某条边界」的场景。区域计数法Zone Counting适合「统计某个区域里同时存在多少人」画一个多边形检测目标中心点是否落在区域内落在区域内则人数 1离开则 -1。这个「瞬时人数」可以换算成「平均滞留时长」对门店热区分析、会议室利用率统计很有用。区域计数和上下行计数的关系不是互斥的可以在通道中间放一个区域计数器做滞留人数统计同时在通道两端各放一条虚拟线区分进出方向。实际项目里两种方法经常组合使用但代码组织上要分开——虚拟线产生的计数是增量事件区域产生的是存量快照。把两者混在一个数组里会很难排查「为什么总人数对不上」。4. 让计数精准起来置信度过滤、去重机制与跨线判定参数4.1 置信度与场景自适应固定摄像头和户外场景完全不同固定摄像头天花板顶装、三脚架固定场景里画面背景基本不变检测模型只需要专注「动的物体是不是人」。这时候置信度阈值可以放心调到 0.5 以上因为同一位置反复出现的误检比如墙上的海报里有人像会被时间维度的连续性过滤掉——min_frames参数就是干这个的。户外场景就麻烦得多树叶摇晃、光影移动、雨滴反光都会产生检测框抖动。我的做法是分两步走先降阈值到 0.25 保召回再在跟踪阶段用min_frames5过滤掉只出现一两帧的瞬时框。这样不会漏人但会引入一个副作用真人从进入画面到被确认 ID 需要 5 帧如果虚拟线画在画面入口附近人可能在第 3 帧就穿线了计数会被漏掉。# 户外场景推荐参数组合 model_conf 0.25 # 降低检测阈值保住召回 track_min_frames 5 # 提高确认帧数过滤瞬时误检 match_thresh 0.75 # 适当放宽 IOU 匹配容忍运动模糊 # 室内固定摄像头推荐参数组合 model_conf 0.5 track_min_frames 3 match_thresh 0.8这两组参数不是拍脑袋定的来自对「计数误差来源」的拆解室内场景的主要误差是检测框抖动导致的重复计数所以提高阈值、缩短确认帧数户外的主要误差是漏检和误检并存所以降阈值保召回但用更长的确认帧数来稳住精度。4.2 去重机制为什么你的计数总是比实际人数多去重是上下行计数最容易出 bug 的地方。最常见的重复计数路径有三种一个人走到虚拟线附近停住了身体左右晃动中心点在线的两侧来回穿越每穿越一次就算一次。解决方法是加「冷却时间」——同一个 ID 完成一次计数后在 2 秒内不再参与任何计数判断。目标在虚拟线上丢失被柱子挡住、被人群遮住重新出现后 ByteTrack 分配新 ID原本只差一步的穿线被当成一个新目标再次计数。解决方法是「虚拟线两侧缓冲带」只有当目标连续 N 帧都在线的某侧、并且在另一侧也连续出现 N 帧后才判定穿越完成。同一人的检测框在相邻两帧间跳变框从 80x80 突然变成 40x120中心点偏移量比正常位移大导致方向判断错误。解决方法是穿线判定用「过去 5 帧的平均中心点」而不是上一帧的点。last_count_time defaultdict(float) COOLDOWN_SECONDS 2.0 # 穿线判定时增加冷却检查 now time.time() if pid in counted_ids: continue # 或者检查 last_count_time[pid] 是否在冷却窗口内 if pid not in last_count_time or (now - last_count_time[pid]) COOLDOWN_SECONDS: if prev_side ! curr_side: if prev_side 1 and curr_side -1: up_count 1 last_count_time[pid] now elif prev_side -1 and curr_side 1: down_count 1 last_count_time[pid] nowlast_count_time这个字典解决的是「同一个 ID 反复穿越」的问题但它不能解决「ID 切换导致的重复」后者必须靠缓冲带逻辑。缓冲区大小取「目标在画面中移动 3-5 帧所经过的像素距离」通常是框高的一半左右。4.3 帧率对计数精度的影响跳帧不是你想象的省力技巧处理超长视频时很多人会想到「每 3 帧处理一次提高处理速度」。这确实能把处理时间缩短到三分之一但精度代价很大人的步行速度大约是每秒 1.2-1.5 米在 1080p 画面里如果摄像头视野是 10 米宽每秒移动约 120-150 像素。30 FPS 下每帧移动 4-5 像素3 帧一跳就是每步 12-15 像素——正好落在检测框抖动范围内。这意味着跳帧后同一个人的框在第 1 帧和第 4 帧之间可能完全不重叠ByteTrack 会判定为两个不同目标。我处理过的一个案例里跳帧让计数从 236 人变成 289 人多出来的全是 ID 断裂造成的重复计数。如果确实需要提速正确做法不是跳帧而是降低推理分辨率把imgsz从 640 降到 480检测耗时减少约 30%精度损失远小于跳帧。或者换用 TensorRT 导出模型那能在几乎不丢精度的情况下带来 2-3 倍加速。4.4 穿线判定的三个边界 case斜穿、回头、并排斜穿问题人的轨迹不是垂直于虚拟线而是以 45 度角斜穿。点乘叉积判断方向依然有效但「上行 / 下行」的语义定义会变模糊。解决方案是给虚拟线一个方向向量穿越角度小于 30 度视为有效大于 30 度视为斜穿按投影方向归类。回头问题人走到线附近又折返。轨迹队列里会有连续多次穿越。冷却时间可以压制重复计数但这会掩盖真实行为——如果 3 秒内同一个 ID 先上后下实际上是「上去拿了个东西又下来」业务逻辑上这算两次。可以扩展状态机每个 ID 维护last_direction只要方向和上次相反且冷却时间已过就计一次。并排问题两个人并肩走检测框高度重叠ByteTrack 可能把两个人合并成一个框持续跟踪穿线时只计 1 次。没有完全可靠的代码级解决方案能做的只有降低match_thresh让匹配更严格、或者提高检测模型输入分辨率让框更精确。业务上如果并排概率很高建议摄像头安装角度改为俯视 45 度以上让重叠面积尽量小。5. 人流量计数避坑手册五个让计数翻车的真实场景5.1 检测框闪烁导致同一人被反复计数现象输出的 CSV 里上行人数在 5 秒内连续增加了 3 次但回看视频发现只有一个人经过。原因检测框在连续帧里时大时小80x80 变 40x120导致框中心点左右摆动穿越虚拟线的时机被反复触发。而 ByteTrack 的 IOU 匹配在框尺寸变化超过 50% 时可能匹配失败产生新 ID所以不仅是「重复触发」还可能是「新 ID 再次触发」。解决在穿线判断前加入两个保护。第一中心点用连续 3 帧的滑动平均第二同一个 ID 完成计数后进入 2 秒冷却。如果这两个保护都加了还是有重复就把tracker.update()前做一次框尺寸滤波——删除宽度小于 20 像素或高度小于 40 像素的框这类小框基本都是半截身子或远处误检。5.2 跟踪 ID 在密集人群中跳变导致上下行统计错乱现象地铁闸机口三个人并排通过输出里 ID 在大幅跳变上一帧甲是 ID 5下一帧甲变成了 ID 9而 ID 5 被分配给了旁边的乙。最终上下行总数 35但人工数实际只有 28。原因ByteTrack 的纯运动关联在目标重叠时不具备区分能力。三个人框重叠 90% 时IOU 匹配会随机分配ID 必然错乱。这是算法原理决定的不是参数能完全救回来的。解决三个层面同时处理。首先是摄像头安装角度改俯视减少人形重叠面积其次是降低match_thresh从 0.8 到 0.7让匹配更宽松但要接受 ID 断裂增加最后是在计数逻辑上把「方向」和「ID」解耦——即使 ID 跳变了只要轨迹先后穿越了虚拟线两侧方向依然能正确判断只是重复计数风险上升。如果场景是超密集每帧超过 15 人考虑换成专门的行人 ReID 方案但推理成本会翻几倍。5.3 低置信度阈值让广告牌、海报上的人像变成假计数现象室内场景画面角落有张促销海报海报里有半个人像。置信度阈值设为 0.25 后海报人像偶尔被检测为 person且因为位置固定、持续存在满足min_frames3后被确认 ID。当人从海报前走过Person 检测框重叠海报框消失又重新出现产生 2-3 次假计数。原因低阈值是为了保户外召回但室内固定场景不需要这个设置。阈值降到 0.25 后置信度 0.3-0.4 的低质量框大量涌入其中包含背景误检。解决设定区域排除ROI Mask。用 OpenCV 的fillPoly把海报区域涂黑检测前把整帧图像与 mask 做一次bitwise_and让模型根本看不到这个区域。另外室内场景把置信度恢复回 0.5 min_frames5误检和假计数同时消失。import numpy as np import cv2 # 定义排除区域多边形 exclude_region np.array([[600, 100], [800, 100], [800, 300], [600, 300]], dtypenp.int32) mask np.ones((frame_height, frame_width), dtypenp.uint8) * 255 cv2.fillPoly(mask, [exclude_region], 0) # 区域置 0其余保持 255 # 每帧检测前应用 mask masked_frame cv2.bitwise_and(frame, frame, maskmask) results model(masked_frame, classes[0], conf0.5, imgsz640, verboseFalse)5.4 虚拟线画在画面边缘导致漏计和半身检测现象虚拟线画在画面底部 20 像素处人从下方进入画面时只露出一个头顶检测框高度只有 15 像素达不到min_frames的连续 3 帧要求人还没被确认 ID 就已经走出了画面计数完全遗漏。原因目标检测框只有在人完整入画的情况下才稳定。而min_frames又要求连续出现多帧两者在画面边缘形成了矛盾。解决把虚拟线往画面内部移动至少距离边缘 10% 的画幅高度1080p 对应约 100 像素。这样人入画后检测框稳定了才有穿线动作。同时在虚拟线外侧加一个「预跟踪区域」——开始跟踪的线Trigger Line和真正计数的线Counting Line分开目标先被 Trigger Line 稳定跟踪再穿越 Counting Line 时计数。5.5 摄像头震动或画面抖动导致整帧检测全部失效现象摄像头安装在天桥护栏上大风天画面周期性左右摆动 3-5 像素。检测框坐标跟着整体偏移ByteTrack 认为所有目标都在移动IOU 匹配大量失败ID 几乎每 3 帧全部更新计数完全不可用。原因ByteTrack 的卡尔曼滤波假设目标是匀加速运动但画面整体的抖动是「全部目标同步平移」这破坏了同方差假设。解决先做视频稳像Video Stabilization。轻量做法是用cv2.estimateAffinePartial2D估计相邻帧的全局变换矩阵然后warpAffine对齐。这会增加每帧约 2-3 毫秒的耗时但换来的是跟踪器的稳定。重型做法是更换硬件用带机械防抖的摄像头。如果无法改硬件也不接受耗时至少把计数需求改为「区域人数统计」——区域统计对抖动不敏感因为区域边界宽几像素的偏移不会改变「中心点是否落在区域内」的结果。注意稳像只适用于固定场景。如果摄像头本身是云台相机在转动稳像会把真正的目标运动当成抖动抹掉计数结果完全失真。这类场景需要用基于位姿的跟踪方案复杂度会高一个量级。6. 验证计数精度与调试技巧用半小时对账代替盲信输出接手任何一个计数项目我都会先花半小时做「对账测试」而不是直接跑长视频。方法很简单取一段 10 分钟的原始视频往前快进着看人工数出上行和下行人数或者找两个人分别数然后拿程序输出的结果对比。这个对比能暴露绝大多数方向上摸不着头脑的问题。对账时关注的指标只有两个漏计率人工数了 100 人程序只计了 92 人和误计率程序比人工多了 7 人。漏计通常来自目标在虚拟线附近被遮挡或检测失败误计几乎都来自重复计数和误检。修正时优先处理误计——因为重复计数往往有规律而漏计大多是零散的逐个补漏效率极低。定位「哪个时间点计数错了」时一个很实用的调试技巧是让程序在每次计数的瞬间把当前帧连同虚拟线一起保存为 JPG 图片# 在触发 count 的位置保存快照帧 if event_triggered: debug_dir fdebug_frames/{direction}_{timestamp} os.makedirs(debug_dir, exist_okTrue) # 绘制虚拟线、目标轨迹、ID 号到帧上 annotated frame.copy() cv2.line(annotated, line_start, line_end, (0, 0, 255), 3) for pid in trajectories: pts np.array(list(trajectories[pid]), dtypenp.int32) cv2.polylines(annotated, [pts], False, (255, 0, 0), 2) cv2.putText(annotated, str(pid), trajectories[pid][-1], cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2) cv2.imwrite(os.path.join(debug_dir, fframe_{frame_idx:06d}.jpg), annotated)出错时打开对应时间点的 JPG一眼就能看出是检测框歪了、ID 跳了还是穿线判断错了。这个技巧比盯着一帧一帧打日志高效得多——省去了「文字日志和画面内容对应不上」的中间环节。对账测试通过后再跑 24 小时的长视频验证稳定性。长时间运行最常见的隐患是内存泄漏trajectories字典里的deque如果只增不减运行 3-4 小时后程序会吃掉几个 GB 内存。在循环里定期清理超过 60 秒没有更新过的 ID就把轨迹和last_count_time记录都删掉。我个人的习惯是保留一套「计数 vs 人工对账」的单元测试。每次改动检测阈值、跟踪器参数或虚拟线位置后用同一段 10 分钟视频跑一遍比对输出是否与改动前一致。不一致时逐帧分析是变好还是变坏——参数调优如果没有回归验证就是在凭感觉做统计。这套方法被我在不同项目里反复用了很多次每次都能救回几个小时的无效调参时间。希望帮到你。本文还有配套的精品资源点击获取