简介:这份资源面向深度学习入门者、计算机视觉方向的学生以及需要完成毕业设计或课程设计的开发者,提供了一套基于YOLO的实时人数统计与追踪完整方案,可用于智能视频监控、人流分析等场景。压缩包共5个文件,约32.8MB,包含2个Python脚本、2个预训练权重文件和1份说明文档:脚本分别实现YOLOv5结合DeepSORT、YOLOv8结合ByteTrack的检测与追踪流程,权重文件为轻量级模型,便于在算力有限的环境中快速推理,说明文档则覆盖安装、配置与使用指引。目前已有52人学习。读者可借此理解从目标检测到多目标跟踪再到人数统计的完整链路,对比两种跟踪算法的实现差异,并在此基础上快速搭建自己的实验环境或二次开发。整体结构紧凑,适合作为入门实践与项目参考。1. 从一段监控视频说起:为什么人数统计总在“最后一米”翻车商场出入口的客流看板、工地安全帽识别、公交站台候车人数预警,这些场景背后都指向同一个技术需求:基于 YOLO 的人数统计与追踪。很多人第一次跑通检测模型时信心满满,框住人、数出框,感觉大功告成;可一旦把视频流接进来,画面里人一走动,计数就开始跳变,同一个人被反复统计,遮挡一下又凭空消失。问题不在 YOLO 本身,而在于“检测”和“统计追踪”是两件事。这个方向适合两类人:一类是想把目标检测落地到真实视频流的工程师,另一类是手里有 YOLO 权重、却卡在“怎么把逐帧检测变成稳定计数”的开发者。它解决的核心不是“能不能检测到人”,而是“怎么让每一帧的检测结果在时间轴上连成一条稳定的轨迹,再按规则去重计数”。本文按“检测→追踪→计数→调参→避坑”的顺序拆开讲,代码可直接抄,参数会说明为什么这么设。先把检测跑稳,再谈追踪,最后才是计数逻辑,顺序反了就是给自己挖坑。2. 检测层怎么搭:YOLO 选型、推理封装与最小可跑通代码2.1 为什么人数统计场景优先选 YOLOv8 而不是更重的模型人数统计对检测器的要求有三个硬指标:召回率要高(漏一个人就少一个)、推理要够快(视频流是实时的)、小目标要稳(远处的人往往只有几十像素)。常见做法是在 YOLOv8n/s/m 之间选,n 最快但小目标召回偏弱,m 精度好但帧率吃紧。我的经验是:室内固定机位、人距镜头近,用 n 或 s 足够;室外广场、俯拍远景,直接上 m,别在 n 上反复调阈值浪费时间。选型还要看部署硬件。CPU 推理时 n 和 s 差距明显,GPU 上 s 和 m 的帧率差往往能接受。另一个容易被忽略的点是输入分辨率:YOLO 默认 640,但远景小目标建议提到 960 或 1280,代价是显存和耗时上升。这里没有万能值,先固定模型,再调分辨率,最后调置信度阈值,一次只动一个变量,否则你根本不知道是哪个参数起了作用。2.2 用 Ultralytics 封装一个只输出“人”的推理函数from ultralytics import YOLO import numpy as np # 加载模型,权重文件放在项目根目录 model = YOLO("yolov8s.pt") def detect_persons(frame, conf=0.35, iou=0.5, imgsz=960): """ 只保留 COCO 类别中的人(class_id=0) conf: 置信度阈值,低于此值的框丢弃 iou: NMS 交并比阈值,控制重叠框合并 imgsz: 推理输入尺寸,远景建议调大 """ results = model.predict( source=frame, conf=conf, iou=iou, imgsz=imgsz, classes=[0], # 关键:只检测人,减少后处理负担 verbose=False ) boxes = results[0].boxes # 转成 [x1, y1, x2, y2, conf] 的 numpy 数组 dets = np.array([ [*box.xyxy[0].tolist(), float(box.conf[0])] for box in boxes ]) return dets if len(dets) else np.empty((0, 5))这段代码的关键在classes=[0]。COCO 数据集里 0 就是 person,加上这个参数后模型只输出人的框,后处理不用再过滤类别,速度会快一点。conf设 0.35 是偏保守的起点,宁可多留几个误检,也别漏检——追踪阶段还能靠轨迹长度把误检滤掉,但漏检是补不回来的。imgsz从 640 提到 960,远景召回通常有明显提升,代价是单帧耗时增加约一倍,需要按硬件实测权衡。2.3 检测结果长什么样:先看清数据结构再谈追踪dets每行是[x1, y1, x2, y2, conf],这是追踪算法的输入。很多人直接把这个数组丢给追踪器就完事,结果发现追踪器要求的是特定格式,比如有的要[x, y, w, h],有的要归一化坐标。转换写错一位,框就整体偏移,表现出来就是“追踪框乱飞”。建议在检测和追踪之间加一层显式转换函数,把格式转换单独测一遍,别混在主流程里。另外要注意检测框的稳定性。同一帧里同一个人的框,两次推理可能差几个像素,这是正常的。追踪器靠的是框之间的 IoU 或外观特征做匹配,几个像素的抖动不影响匹配,但如果检测框时大时小、时有时无,追踪就会频繁断轨。所以检测层的conf不要设太高,保证框的连续性比保证框的精确更重要。3. 追踪层怎么接:ByteTrack 匹配逻辑与 ID 稳定性的三个关键参数3.1 追踪不是“给框编号”,而是“在时间轴上做匹配”追踪的本质是:当前帧检测到若干框,上一帧有若干已知轨迹,要把当前框和已有轨迹配对。配对成功就延续 ID,配对失败就新建轨迹或判定轨迹消失。ByteTrack 的思路是“高低分框都利用”:高分框先和已有轨迹匹配,剩下的低分框再和未匹配轨迹做二次匹配,这样能救回一部分被遮挡导致分数下降的目标。这个设计对人数统计很友好,因为遮挡是断轨的头号原因。理解这一点后,参数就好调了。track_thresh是区分高低分框的阈值,match_thresh是匹配时的 IoU 门槛,track_buffer是轨迹丢失后保留多少帧再删除。这三个参数直接决定 ID 稳不稳,下面逐个说。3.2 用 ByteTrack 把检测框连成轨迹from yolox.tracker.byte_tracker import BYTETracker from types import SimpleNamespace # 追踪器参数,按场景调整 args = SimpleNamespace( track_thresh=0.5, # 高分框阈值,高于此值优先匹配 track_buffer=30, # 轨迹丢失后保留帧数,30帧约等于1秒 match_thresh=0.8, # 匹配 IoU 阈值,越高越严格 mot20=False ) tracker = BYTETracker(args, frame_rate=30) def update_tracks(dets, frame): """ dets: [x1,y1,x2,y2,conf] 的数组 返回: 带 track_id 的轨迹列表 """ # ByteTrack 要求输入为 [x, y, w, h, score] 格式 if len(dets): tlwh = dets.copy() tlwh[:, 2] = dets[:, 2] - dets[:, 0] # w tlwh[:, 3] = dets[:, 3] - dets[:, 1] # h tlwh = tlwh[:, [0, 1, 2, 3, 4]] else: tlwh = np.empty((0, 5)) online_targets = tracker.update(tlwh, [frame.shape[0], frame.shape[1]], [frame.shape[0], frame.shape[1]]) tracks = [] for t in online_targets: x1, y1, w, h = t.tlwh tracks.append({ "id": t.track_id, "box": [x1, y1, x1 + w, y1 + h], "score": t.score }) return tracks这里最容易翻车的是坐标格式。ByteTrack 内部用的是tlwh(左上角 x、y 加宽高),而 YOLO 输出的是xyxy,转换时w = x2 - x1、h = y2 - y1,写反了框就完全错位。track_buffer=30是按 30fps 估的,意思是目标消失后轨迹保留 1 秒,超过就删除。如果视频帧率不是 30,这个值要按比例换算,否则要么轨迹删太快导致 ID 频繁切换,要么留太久导致“幽灵轨迹”。3.3 track_thresh、match_thresh、track_buffer 到底怎么设track_thresh设 0.5 是个稳妥起点。设太高,被遮挡的人分数掉下来就进不了高分匹配,容易断轨;设太低,误检框也会被当成高分框去匹配,可能把轨迹带偏。match_thresh设 0.8 偏严格,适合人走动不快、框重叠不严重的场景;如果人群密集、框重叠多,可以降到 0.6 到 0.7,给匹配更多机会,代价是可能匹配错人。track_buffer是最需要按场景调的。出入口这种目标快速通过的场景,15 到 20 帧就够;室内缓慢移动、遮挡频繁的场景,30 到 50 帧更稳。判断标准很简单:看日志里同一个人的 ID 切换频率,如果一个人走完全程换了三次 ID,就把track_buffer调大;如果画面里出现了已经离开的人的框,就调小。4. 计数层怎么做:越线计数、区域去重与三种计数策略的取舍4.1 计数不是“数框”,而是“数轨迹的穿越事件”最朴素的计数是每帧数框的数量,但同一人连续多帧被数,结果毫无意义。正确做法是给每条轨迹记一个状态,当轨迹的某个参考点(通常用框底边中点,代表脚的位置)从一条线的某一侧移动到另一侧时,计数加一。这样一个人无论被检测到多少帧,只在他真正穿过线的那一刻计一次。参考点选框底边中点而不是框中心,是因为人走动时上半身晃动大,脚的位置更稳定。俯拍场景下这个差异尤其明显,用中心点会导致计数线附近反复触发。这个细节很多教程不讲,但实际调试时能省下大量排查时间。4.2 越线计数的实现与方向判断class LineCounter: def __init__(self, line_start, line_end): self.line = (line_start, line_end) self.count = 0 self.track_state = {} # track_id -> 上一次所在侧 def _side(self, point): """判断点在线的哪一侧,返回正负号""" (x1, y1), (x2, y2) = self.line px, py = point # 叉积符号决定侧别 return (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1) def update(self, tracks): for t in tracks: x1, y1, x2, y2 = t["box"] foot = ((x1 + x2) / 2, y2) # 框底边中点 side = self._side(foot) tid = t["id"] if tid in self.track_state: prev = self.track_state[tid] # 符号变化说明穿过了线 if prev * side < 0: self.count += 1 self.track_state[tid] = side return self.count_side用叉积判断点在线的哪一侧,符号变化就是穿越。这里有个坑:如果参考点正好落在线上,叉积为 0,prev * side < 0不成立,这次穿越会被漏掉。实际使用中可以在线上加一个宽度带,参考点进入带内就锁定状态,离开带再判断方向,能减少临界抖动导致的漏计或重复计。4.3 越线计数、区域计数、密度估计三种策略怎么选越线计数适合出入口、闸机这类有明确通行方向的场景,精度高、逻辑简单。区域计数适合“当前区域有多少人”的看板需求,实现方式是统计框中心落在多边形区域内的轨迹数,难点在区域边界附近的人会反复进出,需要加滞回逻辑。密度估计适合人群极度密集、检测框大量重叠的场景,靠检测已经数不准了,得换回归模型,但那是另一条技术路线。我的建议是:能用越线就别用区域,能用区域就别碰密度估计。越线计数的误差来源少,调参空间小,最容易做稳。区域计数一定要加“进入区域后至少停留 N 帧才计入”的规则,否则边界抖动会让数字乱跳。这三种策略没有优劣,只有场景匹配度,选错了再调参也是事倍功半。5. 避坑与排查:人数统计从能跑到能用之间隔着的五个坑5.1 同一个人被反复计数现象是计数只增不减,或者一个人走过去数字加了好几次。原因通常是追踪 ID 不稳定,同一个人被分配了多个 ID,每个新 ID 都被当成一次穿越。解决分两步:先把track_buffer调大,给轨迹更多恢复时间;再检查检测框是否连续,如果检测本身时有时无,追踪器再强也救不回来,这时候要降conf或提imgsz。5.2 计数偏少,明显有人走过却没计上现象是画面里人明明穿过了线,数字没动。原因可能是参考点选错,比如用了框中心,人弯腰或遮挡时中心点没越过线;也可能是穿越判定太严格,参考点擦着线过去没触发符号变化。解决方法是把参考点改成框底边中点,并在计数线上加一个容差带,参考点进入带内就记录待定状态,离开带时按方向判定。5.3 画面边缘的人频繁触发计数现象是计数线靠近画面边缘时,人在边缘进出导致数字乱跳。原因是边缘处检测框不完整,参考点位置不稳定,在线的两侧反复横跳。解决方法是在计数逻辑里加一个“轨迹存活帧数”门槛,新出现的轨迹至少存活 5 帧才允许参与计数,这样边缘闪现的误检和刚进画面还没稳定的轨迹都会被过滤掉。5.4 帧率变化后参数全部失效现象是换了一个视频源,之前调好的参数全不好使了。原因是track_buffer是按帧数计的,帧率一变,同样的帧数对应的真实时间就变了。解决方法是把track_buffer按“秒”来设计,代码里用fps * 期望秒数动态计算,而不是写死一个数字。同理,任何和“持续多少帧”相关的逻辑都要做帧率归一化。5.5 多人并排走时 ID 互换现象是两个人挨着走,追踪 ID 突然对调,导致计数错乱。原因是两人框重叠严重时,IoU 匹配分不清谁是谁。解决方法是引入外观特征做二次匹配,或者接受这个误差——在人数统计场景里,ID 互换只要不发生在计数线附近,对总数影响很小。如果计数线附近经常并排通行,可以考虑把计数线设在通道较窄、人被迫分开的位置。6. 进阶技巧:用轨迹平滑和计数校验把误差压到可接受范围检测框抖动是计数误差的隐形来源。一个实用技巧是对每条轨迹的参考点做滑动平均,用最近 5 帧的底边中点均值代替当前帧的原始值,再去做穿越判定。这样单帧的检测抖动被平滑掉,穿越事件只在真实移动时触发。代价是计数会延迟几帧,但对看板类应用完全可接受。from collections import deque class SmoothCounter(LineCounter): def __init__(self, line_start, line_end, window=5): super().__init__(line_start, line_end) self.history = {} # track_id -> 最近若干帧的参考点 self.window = window def update(self, tracks): for t in tracks: x1, y1, x2, y2 = t["box"] foot = ((x1 + x2) / 2, y2) tid = t["id"] buf = self.history.setdefault(tid, deque(maxlen=self.window)) buf.append(foot) # 用均值点做穿越判定 avg_x = sum(p[0] for p in buf) / len(buf) avg_y = sum(p[1] for p in buf) / len(buf) side = self._side((avg_x, avg_y)) if tid in self.track_state: if self.track_state[tid] * side < 0: self.count += 1 self.track_state[tid] = side return self.countwindow=5是平滑窗口,太小起不到平滑作用,太大则计数延迟明显。5 帧在 30fps 下约 0.17 秒,基本无感。这个技巧对固定机位效果最好,如果镜头本身在动,参考点会整体漂移,平滑反而会引入误差,这时候要先做背景对齐或者干脆换用区域计数。另一个进阶做法是加计数校验:维护一个“当前画面内轨迹数”和“累计穿越数”,正常情况下累计穿越数应该大于等于当前轨迹数,如果出现累计数小于当前数,说明追踪出了严重问题,可以触发告警或自动重置。这个校验逻辑不复杂,但能在无人值守时帮你发现参数失效。我自己踩过最深的坑是过早优化计数逻辑,而忽略了检测框的连续性。后来养成一个习惯:任何计数异常,先回放检测结果的可视化视频,看框是不是稳定,框稳了再查追踪,追踪稳了再查计数。这个顺序能省下大量瞎调参数的时间。希望帮到你。本文还有配套的精品资源,点击获取
简介:这份资源面向深度学习入门者、计算机视觉方向的学生以及需要完成毕业设计或课程设计的开发者,提供了一套基于YOLO的实时人数统计与追踪完整方案,可用于智能视频监控、人流分析等场景。压缩包共5个文件,约32.8MB,包含2个Python脚本、2个预训练权重文件和1份说明文档:脚本分别实现YOLOv5结合DeepSORT、YOLOv8结合ByteTrack的检测与追踪流程,权重文件为轻量级模型,便于在算力有限的环境中快速推理,说明文档则覆盖安装、配置与使用指引。目前已有52人学习。读者可借此理解从目标检测到多目标跟踪再到人数统计的完整链路,对比两种跟踪算法的实现差异,并在此基础上快速搭建自己的实验环境或二次开发。整体结构紧凑,适合作为入门实践与项目参考。1. 从一段监控视频说起:为什么人数统计总在“最后一米”翻车商场出入口的客流看板、工地安全帽识别、公交站台候车人数预警,这些场景背后都指向同一个技术需求:基于 YOLO 的人数统计与追踪。很多人第一次跑通检测模型时信心满满,框住人、数出框,感觉大功告成;可一旦把视频流接进来,画面里人一走动,计数就开始跳变,同一个人被反复统计,遮挡一下又凭空消失。问题不在 YOLO 本身,而在于“检测”和“统计追踪”是两件事。这个方向适合两类人:一类是想把目标检测落地到真实视频流的工程师,另一类是手里有 YOLO 权重、却卡在“怎么把逐帧检测变成稳定计数”的开发者。它解决的核心不是“能不能检测到人”,而是“怎么让每一帧的检测结果在时间轴上连成一条稳定的轨迹,再按规则去重计数”。本文按“检测→追踪→计数→调参→避坑”的顺序拆开讲,代码可直接抄,参数会说明为什么这么设。先把检测跑稳,再谈追踪,最后才是计数逻辑,顺序反了就是给自己挖坑。2. 检测层怎么搭:YOLO 选型、推理封装与最小可跑通代码2.1 为什么人数统计场景优先选 YOLOv8 而不是更重的模型人数统计对检测器的要求有三个硬指标:召回率要高(漏一个人就少一个)、推理要够快(视频流是实时的)、小目标要稳(远处的人往往只有几十像素)。常见做法是在 YOLOv8n/s/m 之间选,n 最快但小目标召回偏弱,m 精度好但帧率吃紧。我的经验是:室内固定机位、人距镜头近,用 n 或 s 足够;室外广场、俯拍远景,直接上 m,别在 n 上反复调阈值浪费时间。选型还要看部署硬件。CPU 推理时 n 和 s 差距明显,GPU 上 s 和 m 的帧率差往往能接受。另一个容易被忽略的点是输入分辨率:YOLO 默认 640,但远景小目标建议提到 960 或 1280,代价是显存和耗时上升。这里没有万能值,先固定模型,再调分辨率,最后调置信度阈值,一次只动一个变量,否则你根本不知道是哪个参数起了作用。2.2 用 Ultralytics 封装一个只输出“人”的推理函数from ultralytics import YOLO import numpy as np # 加载模型,权重文件放在项目根目录 model = YOLO("yolov8s.pt") def detect_persons(frame, conf=0.35, iou=0.5, imgsz=960): """ 只保留 COCO 类别中的人(class_id=0) conf: 置信度阈值,低于此值的框丢弃 iou: NMS 交并比阈值,控制重叠框合并 imgsz: 推理输入尺寸,远景建议调大 """ results = model.predict( source=frame, conf=conf, iou=iou, imgsz=imgsz, classes=[0], # 关键:只检测人,减少后处理负担 verbose=False ) boxes = results[0].boxes # 转成 [x1, y1, x2, y2, conf] 的 numpy 数组 dets = np.array([ [*box.xyxy[0].tolist(), float(box.conf[0])] for box in boxes ]) return dets if len(dets) else np.empty((0, 5))这段代码的关键在classes=[0]。COCO 数据集里 0 就是 person,加上这个参数后模型只输出人的框,后处理不用再过滤类别,速度会快一点。conf设 0.35 是偏保守的起点,宁可多留几个误检,也别漏检——追踪阶段还能靠轨迹长度把误检滤掉,但漏检是补不回来的。imgsz从 640 提到 960,远景召回通常有明显提升,代价是单帧耗时增加约一倍,需要按硬件实测权衡。2.3 检测结果长什么样:先看清数据结构再谈追踪dets每行是[x1, y1, x2, y2, conf],这是追踪算法的输入。很多人直接把这个数组丢给追踪器就完事,结果发现追踪器要求的是特定格式,比如有的要[x, y, w, h],有的要归一化坐标。转换写错一位,框就整体偏移,表现出来就是“追踪框乱飞”。建议在检测和追踪之间加一层显式转换函数,把格式转换单独测一遍,别混在主流程里。另外要注意检测框的稳定性。同一帧里同一个人的框,两次推理可能差几个像素,这是正常的。追踪器靠的是框之间的 IoU 或外观特征做匹配,几个像素的抖动不影响匹配,但如果检测框时大时小、时有时无,追踪就会频繁断轨。所以检测层的conf不要设太高,保证框的连续性比保证框的精确更重要。3. 追踪层怎么接:ByteTrack 匹配逻辑与 ID 稳定性的三个关键参数3.1 追踪不是“给框编号”,而是“在时间轴上做匹配”追踪的本质是:当前帧检测到若干框,上一帧有若干已知轨迹,要把当前框和已有轨迹配对。配对成功就延续 ID,配对失败就新建轨迹或判定轨迹消失。ByteTrack 的思路是“高低分框都利用”:高分框先和已有轨迹匹配,剩下的低分框再和未匹配轨迹做二次匹配,这样能救回一部分被遮挡导致分数下降的目标。这个设计对人数统计很友好,因为遮挡是断轨的头号原因。理解这一点后,参数就好调了。track_thresh是区分高低分框的阈值,match_thresh是匹配时的 IoU 门槛,track_buffer是轨迹丢失后保留多少帧再删除。这三个参数直接决定 ID 稳不稳,下面逐个说。3.2 用 ByteTrack 把检测框连成轨迹from yolox.tracker.byte_tracker import BYTETracker from types import SimpleNamespace # 追踪器参数,按场景调整 args = SimpleNamespace( track_thresh=0.5, # 高分框阈值,高于此值优先匹配 track_buffer=30, # 轨迹丢失后保留帧数,30帧约等于1秒 match_thresh=0.8, # 匹配 IoU 阈值,越高越严格 mot20=False ) tracker = BYTETracker(args, frame_rate=30) def update_tracks(dets, frame): """ dets: [x1,y1,x2,y2,conf] 的数组 返回: 带 track_id 的轨迹列表 """ # ByteTrack 要求输入为 [x, y, w, h, score] 格式 if len(dets): tlwh = dets.copy() tlwh[:, 2] = dets[:, 2] - dets[:, 0] # w tlwh[:, 3] = dets[:, 3] - dets[:, 1] # h tlwh = tlwh[:, [0, 1, 2, 3, 4]] else: tlwh = np.empty((0, 5)) online_targets = tracker.update(tlwh, [frame.shape[0], frame.shape[1]], [frame.shape[0], frame.shape[1]]) tracks = [] for t in online_targets: x1, y1, w, h = t.tlwh tracks.append({ "id": t.track_id, "box": [x1, y1, x1 + w, y1 + h], "score": t.score }) return tracks这里最容易翻车的是坐标格式。ByteTrack 内部用的是tlwh(左上角 x、y 加宽高),而 YOLO 输出的是xyxy,转换时w = x2 - x1、h = y2 - y1,写反了框就完全错位。track_buffer=30是按 30fps 估的,意思是目标消失后轨迹保留 1 秒,超过就删除。如果视频帧率不是 30,这个值要按比例换算,否则要么轨迹删太快导致 ID 频繁切换,要么留太久导致“幽灵轨迹”。3.3 track_thresh、match_thresh、track_buffer 到底怎么设track_thresh设 0.5 是个稳妥起点。设太高,被遮挡的人分数掉下来就进不了高分匹配,容易断轨;设太低,误检框也会被当成高分框去匹配,可能把轨迹带偏。match_thresh设 0.8 偏严格,适合人走动不快、框重叠不严重的场景;如果人群密集、框重叠多,可以降到 0.6 到 0.7,给匹配更多机会,代价是可能匹配错人。track_buffer是最需要按场景调的。出入口这种目标快速通过的场景,15 到 20 帧就够;室内缓慢移动、遮挡频繁的场景,30 到 50 帧更稳。判断标准很简单:看日志里同一个人的 ID 切换频率,如果一个人走完全程换了三次 ID,就把track_buffer调大;如果画面里出现了已经离开的人的框,就调小。4. 计数层怎么做:越线计数、区域去重与三种计数策略的取舍4.1 计数不是“数框”,而是“数轨迹的穿越事件”最朴素的计数是每帧数框的数量,但同一人连续多帧被数,结果毫无意义。正确做法是给每条轨迹记一个状态,当轨迹的某个参考点(通常用框底边中点,代表脚的位置)从一条线的某一侧移动到另一侧时,计数加一。这样一个人无论被检测到多少帧,只在他真正穿过线的那一刻计一次。参考点选框底边中点而不是框中心,是因为人走动时上半身晃动大,脚的位置更稳定。俯拍场景下这个差异尤其明显,用中心点会导致计数线附近反复触发。这个细节很多教程不讲,但实际调试时能省下大量排查时间。4.2 越线计数的实现与方向判断class LineCounter: def __init__(self, line_start, line_end): self.line = (line_start, line_end) self.count = 0 self.track_state = {} # track_id -> 上一次所在侧 def _side(self, point): """判断点在线的哪一侧,返回正负号""" (x1, y1), (x2, y2) = self.line px, py = point # 叉积符号决定侧别 return (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1) def update(self, tracks): for t in tracks: x1, y1, x2, y2 = t["box"] foot = ((x1 + x2) / 2, y2) # 框底边中点 side = self._side(foot) tid = t["id"] if tid in self.track_state: prev = self.track_state[tid] # 符号变化说明穿过了线 if prev * side < 0: self.count += 1 self.track_state[tid] = side return self.count_side用叉积判断点在线的哪一侧,符号变化就是穿越。这里有个坑:如果参考点正好落在线上,叉积为 0,prev * side < 0不成立,这次穿越会被漏掉。实际使用中可以在线上加一个宽度带,参考点进入带内就锁定状态,离开带再判断方向,能减少临界抖动导致的漏计或重复计。4.3 越线计数、区域计数、密度估计三种策略怎么选越线计数适合出入口、闸机这类有明确通行方向的场景,精度高、逻辑简单。区域计数适合“当前区域有多少人”的看板需求,实现方式是统计框中心落在多边形区域内的轨迹数,难点在区域边界附近的人会反复进出,需要加滞回逻辑。密度估计适合人群极度密集、检测框大量重叠的场景,靠检测已经数不准了,得换回归模型,但那是另一条技术路线。我的建议是:能用越线就别用区域,能用区域就别碰密度估计。越线计数的误差来源少,调参空间小,最容易做稳。区域计数一定要加“进入区域后至少停留 N 帧才计入”的规则,否则边界抖动会让数字乱跳。这三种策略没有优劣,只有场景匹配度,选错了再调参也是事倍功半。5. 避坑与排查:人数统计从能跑到能用之间隔着的五个坑5.1 同一个人被反复计数现象是计数只增不减,或者一个人走过去数字加了好几次。原因通常是追踪 ID 不稳定,同一个人被分配了多个 ID,每个新 ID 都被当成一次穿越。解决分两步:先把track_buffer调大,给轨迹更多恢复时间;再检查检测框是否连续,如果检测本身时有时无,追踪器再强也救不回来,这时候要降conf或提imgsz。5.2 计数偏少,明显有人走过却没计上现象是画面里人明明穿过了线,数字没动。原因可能是参考点选错,比如用了框中心,人弯腰或遮挡时中心点没越过线;也可能是穿越判定太严格,参考点擦着线过去没触发符号变化。解决方法是把参考点改成框底边中点,并在计数线上加一个容差带,参考点进入带内就记录待定状态,离开带时按方向判定。5.3 画面边缘的人频繁触发计数现象是计数线靠近画面边缘时,人在边缘进出导致数字乱跳。原因是边缘处检测框不完整,参考点位置不稳定,在线的两侧反复横跳。解决方法是在计数逻辑里加一个“轨迹存活帧数”门槛,新出现的轨迹至少存活 5 帧才允许参与计数,这样边缘闪现的误检和刚进画面还没稳定的轨迹都会被过滤掉。5.4 帧率变化后参数全部失效现象是换了一个视频源,之前调好的参数全不好使了。原因是track_buffer是按帧数计的,帧率一变,同样的帧数对应的真实时间就变了。解决方法是把track_buffer按“秒”来设计,代码里用fps * 期望秒数动态计算,而不是写死一个数字。同理,任何和“持续多少帧”相关的逻辑都要做帧率归一化。5.5 多人并排走时 ID 互换现象是两个人挨着走,追踪 ID 突然对调,导致计数错乱。原因是两人框重叠严重时,IoU 匹配分不清谁是谁。解决方法是引入外观特征做二次匹配,或者接受这个误差——在人数统计场景里,ID 互换只要不发生在计数线附近,对总数影响很小。如果计数线附近经常并排通行,可以考虑把计数线设在通道较窄、人被迫分开的位置。6. 进阶技巧:用轨迹平滑和计数校验把误差压到可接受范围检测框抖动是计数误差的隐形来源。一个实用技巧是对每条轨迹的参考点做滑动平均,用最近 5 帧的底边中点均值代替当前帧的原始值,再去做穿越判定。这样单帧的检测抖动被平滑掉,穿越事件只在真实移动时触发。代价是计数会延迟几帧,但对看板类应用完全可接受。from collections import deque class SmoothCounter(LineCounter): def __init__(self, line_start, line_end, window=5): super().__init__(line_start, line_end) self.history = {} # track_id -> 最近若干帧的参考点 self.window = window def update(self, tracks): for t in tracks: x1, y1, x2, y2 = t["box"] foot = ((x1 + x2) / 2, y2) tid = t["id"] buf = self.history.setdefault(tid, deque(maxlen=self.window)) buf.append(foot) # 用均值点做穿越判定 avg_x = sum(p[0] for p in buf) / len(buf) avg_y = sum(p[1] for p in buf) / len(buf) side = self._side((avg_x, avg_y)) if tid in self.track_state: if self.track_state[tid] * side < 0: self.count += 1 self.track_state[tid] = side return self.countwindow=5是平滑窗口,太小起不到平滑作用,太大则计数延迟明显。5 帧在 30fps 下约 0.17 秒,基本无感。这个技巧对固定机位效果最好,如果镜头本身在动,参考点会整体漂移,平滑反而会引入误差,这时候要先做背景对齐或者干脆换用区域计数。另一个进阶做法是加计数校验:维护一个“当前画面内轨迹数”和“累计穿越数”,正常情况下累计穿越数应该大于等于当前轨迹数,如果出现累计数小于当前数,说明追踪出了严重问题,可以触发告警或自动重置。这个校验逻辑不复杂,但能在无人值守时帮你发现参数失效。我自己踩过最深的坑是过早优化计数逻辑,而忽略了检测框的连续性。后来养成一个习惯:任何计数异常,先回放检测结果的可视化视频,看框是不是稳定,框稳了再查追踪,追踪稳了再查计数。这个顺序能省下大量瞎调参数的时间。希望帮到你。本文还有配套的精品资源,点击获取