摄像头怎么数人:实时区域人数统计入门,以及数字为什么会跳

目录


一、先排除几个显而易见的方案

需求很朴素:一个摄像头对着会议室,实时告诉我现在有几个人在里面。

在动手写代码之前,值得先把”看起来更简单”的路子排掉——因为每一条都死在同一个地方:它们只知道”有人经过”,不知道”此刻有几个”。

方案原理为什么不行
红外对射 / 光幕两人并排走,光路被挡住一次两人同行算一个;也不知道”当下剩几个”,只能累计进出
门禁 / 闸机刷卡才放行只对”愿意配合的人”有效
WiFi 探针手机连过 AP 的痕迹人得带手机,且分不清员工和访客
UWB / 蓝牙信标室内定位同上,需要人携带标签
人脸识别数人头检测到人脸就 +1侧脸、低头、戴帽子直接失效;而且会议室装人脸识别,隐私上基本过不了
人体传感器(PIR)红外热释电只告诉你有”活动”,站着不动就不产生信号

排到最后剩下一个:普通摄像头 + 计算机视觉。它是唯一同时满足三条的——不需要人配合、知道”谁在哪”而不只是”有人没”、不给隐私添新麻烦(只输出一个数字)。

代价是,你得教会计算机”看见”。

二、计算机眼里的画面长什么样

一张 1920×1080 的彩色图,在程序里就是一个 1920 × 1080 × 3 的数组。没有”人”,没有”桌子”,只有一大堆 0 到 255 之间的整数。

打个后端能立刻理解的比方:这就像一张没有 schema 的表。你拿到了一亿行数据,但不知道哪一列是”姓名”、哪一列是”金额”——列名、类型、外键全都没有。

传统的写代码方式在这里直接失效。你写不出 SELECT COUNT(*) WHERE ...,因为你根本没有可查询的字段。你要做的不是写查询,而是造出这些字段本身。

这件事按能力递进分成三层,值得先分清,因为后面”为什么数字不对”的答案,往往就藏在你搞混了哪一层:

任务输入输出能数人吗
分类一张图一个标签(“有人” / “没人”)❌ 不知道几个
检测一张图N 个框 + 类别 + 置信度❌ 会重复计数、会漏
检测 + 跟踪一段视频每个框带一个稳定的 ID✅

如果你读过本号之前那篇《AI 是如何识别一只猫的》,那篇讲的是第一层。本文从第二层接着往下,最后落到第三层。

一句话概括这三层的递进:分类回答”有没有”,检测回答”在哪里”,跟踪回答”哪一个”。

三、从头到尾走一遍

在钻进任何一层之前,先看一眼全景。这是本文的地图——后面每一节,都对应这张图上的一个环节。

flowchart LR
    A["一帧画面"] --> B["检测"]
    B --> C["NMS"]
    C --> D["跟踪"]
    D --> E["区域判定"]
    E --> F["去抖"]
    F --> G["一个数字"]
步骤它在解决什么问题产物是什么
检测画面里哪里有人一堆框 + 置信度
NMS同一个人被检出好几个框,得收拾去重后的框
跟踪检测每帧都从零开始,没有记忆每个框配上一个稳定 ID
区域判定这个人此刻在不在我划的范围内每个 ID 的”在 / 不在”
去抖判定在边界上会来回抖稳定下来的”在 / 不在”
计数—一个数字

这张表有一个读法,是全文的钥匙:

每一步的产物,都是下一步的输入。

所以读后面每一节时,都可以问同一个问题:它为什么必须存在?

答案几乎总是同一个——上一步留下的产物,不够用。

检测给出了”哪里有人”,但它每帧都从零开始、还会把同一个人报好几遍——所以需要 NMS 和跟踪。跟踪给出了稳定的 ID,但没回答”在不在我关心的地方”——所以需要区域判定。区域判定给出了答案,但答案在边界上会抖——所以需要去抖。

这条链不是六个独立的技术,是一条被”上一步不够用”一步步逼出来的路。

四、检测:画面里哪里有人

第一步就是把那张”没有 schema 的表”变成一点点结构。现在这一步基本不用自己训练——装个库,几行代码就能拿到结果:

from ultralytics import YOLO

model = YOLO("yolo11n.pt")           # 首次运行会自动下载权重
results = model("frame.jpg")[0]

print(results.boxes.xyxy)   # 框的坐标:[[x1, y1, x2, y2], ...]
print(results.boxes.conf)   # 置信度:[0.91, 0.87, ...]
print(results.boxes.cls)    # 类别编号:[0., 0., ...]

这三行就是检测这一步的产物,三个词要认识:

  • bbox(边界框):用一个矩形圈住目标,记录左上角和右下角两个点。就四个数,没有别的信息。
  • 置信度 conf:模型有多确信”这里面是个人”。0.91 就是它挺确信。
  • 类别 cls:模型能认的类别有很多种,其中**「人」恰好是第 0 类**。所以 classes=[0] 就是在说”我只要人,别的都别报”。

4.1 先回答一个问题:这个模型是哪来的?

认识完产物,回到刚才那行代码——YOLO("yolo11n.pt") 里的东西是什么?它是一个已经训练好的模型,下载下来就能用。

你可能会想:我不用自己训练吗?

不用。但这里冒出一串词,值得一次说清。用做菜打比方最省事:

词打个比方它实际是什么
YOLO一本”做目标检测”的菜谱一种专门用来识别物体、并标出位置的方法
模型厨师本人训练好的网络,能对新图片做判断
训练集平时练手的菜模型学习用的数据
验证集模拟试菜训练时用来检查模型学得怎么样
测试集正式比赛最后评估模型真实水平
推理厨师正式做菜用训练好的模型处理新图片

这几个词串起来是这样一个过程:

先有一本菜谱(YOLO 这个方法),然后拿一大堆练手菜(训练集)反复练,练出一个厨师(模型);练的过程中不时来一次模拟试菜(验证集),尝尝火候对不对;最后上正式比赛(测试集),检验真实水平。练成之后,每次有新客人点菜让他做——那叫推理。

而你这个项目,只做最后那一步。

  • 你不需要训练集、验证集、测试集
  • 你不需要训练,也不需要 GPU
  • 你要做的只有推理——把图片喂进去,拿结果

为什么可以这样? 因为「人」这个类别太通用了。识别”人”这件事,别人已经用海量图片练过,练出来的厨师换个厨房照样认得人。

那什么时候才轮到你练? 当你要认的东西不在它的菜谱里——比如某种特定的工服、某个型号的零件。到那时候,你才需要准备自己的训练集,自己从头练一个。

4.2 那”训练”到底是在干什么?

上表里”练手菜 → 厨师”这一步,值得再往下问一层:怎么练的?

模型本质上就是一大串数字。 你可以把它想成一个极其复杂的公式——输入一张图片(也就是一堆数字),经过层层计算,输出”哪里有东西、是什么”。公式的形状是人设计的,但里面每个数字的值,是练出来的。

这句话如果费解,本号那篇《AI 是如何识别一只猫的》就是从零讲这件事的:一个神经元、几个参数、怎么一步步调。那里讲的是最小版本,这里是同一件事放大几百万倍。

训练的做法,朴素到有点意外:

  1. 先给那串数字一个随机初值——这时候模型完全在瞎猜
  2. 拿一张训练图片喂进去,让它猜
  3. 把它猜的和标准答案比一比,算出一个”错得有多离谱”的数字
  4. 按这个数字,把每个参数往”错得更少”的方向挪一点点
  5. 回到第 2 步,重复几百万次

练完,那串数字就固定下来,成了”厨师的手艺”。这就是”训练”的全部。

那它和 LLM 是什么关系? 是同一件事的两种任务:

练的是什么输入输出
YOLO这张图里哪里有东西、是什么图片一堆框 + 类别
LLM下一个词是什么一段文字下一个词的概率分布

都是神经网络,都是”拿数据反复调那串数字”练出来的。 区别只在任务,以及由此带来的网络结构差异。

本号的《大模型训练笔记》三篇,讲的就是右边这一列的完整链路——预训练、SFT、LoRA、强化学习,以及每一步在解决什么问题。

本文左边这一列的”训练”,原理和它一模一样,只是把”猜下一个词”换成了”猜框在哪”。

4.3 但它有个毛病:同一个人会被报好几次

模型对同一个人通常会吐出好几个框——一个框住全身,一个框住上半身,角度略有差异。如果直接数框的个数,你要么把一个人数成三个,要么完全没法用。

这就是 NMS(非极大值抑制)存在的理由。 它的做法很直白:把所有框按置信度从高到低排,取最高的那个当真的,然后把跟它重叠得太厉害的框全删掉——因为那些大概率是同一个人。

你不需要知道它怎么算重叠、阈值怎么调。 记住它在链路上的位置就够了:它是检测和跟踪之间的一道清洁工序,负责把”同一个人被报了好几遍”这件事收拾干净。

(它留下一个小尾巴:这个”删哪个”的决定是每帧独立做的。这一帧删的是 A,下一帧可能删的是 B。同一个人的框因此在帧与帧之间会轻微跳动——这是后面数字会跳的根源之一,先记着。)

4.4 模型名里的 n / s / m / l / x

yolo11n.pt 那个 n 是尺寸档位:n 最快最弱,x 最慢最准。

一个常见的新手错误是一上来就上 x——那是给”漏检一个就出事故”的场景准备的。会议室数人对精度不敏感(漏一个也不致命),n 档在普通 CPU 上就能跑实时,先用它。

五、跟踪:给每个人一个稳定的编号

上一节的产物有一个致命缺陷:它没有记忆。

假设你跳过跟踪,直接每帧数框的个数。会发生什么:

  • 第 1 帧:3 个框 → “3 个人” ✅
  • 第 2 帧:模型打了个喷嚏,其中一个人置信度掉到 0.34(低于阈值),只检出 2 个框 → “2 个人” ❌
  • 第 3 帧:那人恢复了,但另一个人因为 NMS 抖动被合并 → “2 个人” ❌

单帧检测不知道这一帧的框和上一帧的框是不是同一个人。每一帧都是从零开始。

跟踪要做的事情就一句话:给每个目标发一个稳定的 ID,并跨帧维持它。 有了 ID,“3 个人”才有意义——它意味着”这 3 个 ID 还在”。

5.1 它大概怎么做(只看作用)

拿 ByteTrack 举例,一次更新分三步。这里只讲每一步在解决什么问题:

第一步:预测。 人不会瞬移,上一帧站在左边的人这一帧大概率还在附近。所以先用历史轨迹推测”他这一帧大概会在哪”。(这套推算叫卡尔曼滤波——你手机里的 GPS 定位用的是同一类东西。)

第二步:匹配。 现在手上有两组东西:一组是”我推测这几个目标会出现在这里”,另一组是”模型实际检出了这几个框”。要把它们配对起来,判断谁是谁。(这一步在算法上叫匈牙利算法,说白了就是”两组东西怎么配成一对”。)

第三步:两阶段关联——这才是 ByteTrack 的核心贡献。

先拿高置信度的框去做匹配。匹配完之后,剩下一批低置信度的框,怎么办?普通做法是直接扔掉。

ByteTrack 说:别扔。 一个人被挡住一半的时候,模型的置信度会掉,但那个框的位置往往还是对的。所以它再拿这些低分框去”捞”一遍还没配对上的轨迹。

这个改动看起来很小,但它正是”人一多就丢人”问题的第一道缓解。

5.2 一个后端的类比

如果你写过分布式系统,这一节的内容你其实已经不陌生了:

跟踪里的概念后端里的对应物
每帧的检测结果无状态的一次观测(比如一条 access log)
tracker_idsession id
用历史轨迹推测当前位置状态预测(根据历史推断当前状态)
把预测和实测配对请求关联 / 去重
目标消失后清理状态session 超时

检测是”每帧产生一批无状态观测”,跟踪是”维护一批有状态的会话,把新观测归到对应会话上”。 tracker_id 就是 session id——这个类比后面讲”人走了为什么数字不减”的时候还会用到。

六、区域判定:他到底在不在里面

现在每个人都有稳定 ID 了。接下来要回答的问题变了:这个人,在不在我划的那个区域里?

最直觉的做法是取框的中心点,看它落不落在多边形内。这是错的。

道理很简单:

人站在哪里,是由脚决定的,不是由身体中心决定的。

框是套在整个人身上的,框中心大概在人的胸口或者腰部高度。但”这个人此刻的位置”,在地面上对应的投影是脚。

画出来就一目了然(斜俯视视角,最常见的安装方式):

                 ┌─────────┐
                 │   头    │
                 │         │
                 │   身    │   ← 框中心在这一带(胸口高度)
                 │         │
                 └──┬───┬──┘
                    │   │
                    ▼   ▼      ← 脚点:bbox 底边的中点
    ────────────────╧═══╧───────────────  区域边界
                    ↑
              人其实还站在区域外面

人站在边界外侧,但身体前倾靠近,框中心可能已经越过边界了。用框中心判定,这个人就会一会儿算进去、一会儿算出来。

用框底边中点(俗称脚点)就不一样了:它对应的是人在地面上的落点,和”人在不在这个区域”是同一个语义。

一个有力的旁证:supervision 这个库的 PolygonZone,默认参数就是 triggering_anchors=(Position.BOTTOM_CENTER,);Frigate(另一个开源安防项目)的文档里也写着,一个对象在不在区域里,是按它边界框的底边中点来判断的,而不是看框和区域重叠了多少。

两个彼此独立的项目做了完全相同的选择。这不是巧合——这就是这个问题的正确答案。

七、去抖:数字为什么会跳

到这一步,链路已经通了。但你把代码跑起来,大概率会看到这个现象:

一个人站在区域边界上不动,屏幕上的数字在 3 和 4 之间反复横跳。

这是整个项目里最影响观感、也最容易被忽略的问题。它有三个根源,每一个都来自前面某一节的产物:

  1. 框本身在抖 —— NMS 的”删哪个”是逐帧独立决定的(第四节埋的那个尾巴),输出有 ± 几个像素的噪声,脚点位置跟着抖
  2. 脚点在边界线上来回穿越 —— 抖动几个像素,就是在”内”和”外”之间反复横跳(第六节的判定逻辑没错,是输入在抖)
  3. 跟踪 ID 跳变 —— 遮挡、两人交叉走过时 ID 可能互换,导致一个”人”凭空消失又出现(第五节留下的问题)

三条根源,层次完全不同:第一条是检测的噪声,第二条是判定的灵敏度,第三条是跟踪的缺陷。所以”把阈值调大一点”治不了本——它只压得住第二条。

7.1 解法:滞回

这个词来自电子学。机械开关按下去的时候,触点会抖动几十毫秒,导致按一次被识别成按了好几次。施密特触发器的解法是:不用一条阈值线,用两条。

  • 电压升到 0.7 以上,才算”按下”
  • 电压降到 0.3 以下,才算”松开”
  • 中间 0.3~0.7 这一段,保持上一次的状态不变

中间这段”死区”就是抗抖动的关键。人数统计照搬这个思路:

  • 目标要连续 N 帧都在区域内,才算”进来了”
  • 目标要连续 M 帧都在区域外,才算”出去了”
  • 中途反悔(比如连续 N 帧还没满就又出去了),视为抖动,计数不变

翻译成状态机,就是这样:

stateDiagram-v2
    [*] --> 区外
    区外 --> 进入中: 脚点进入区域
    进入中 --> 区内: 连续 N 帧都在区内
    进入中 --> 区外: 中途出界(抖动,丢弃)
    区内 --> 离开中: 脚点离开区域
    离开中 --> 区内: 连续 M 帧内又回来了(抖动,忽略)
    离开中 --> 区外: 连续 M 帧都在区外
    区内 --> 区外: 目标消失(TTL 清理)

这张图里最关键的是 离开中 → 区内 那条边。 它正是抗抖动的核心:人在边界上抖了 3 帧又回来,离开计数没攒够 M,状态原地弹回”区内”,数字全程没有变化。

为什么必须两条线? 因为”太灵敏”和”太迟钝”是两个互相冲突的目标,单阈值只能二选一:调大了,快速穿过区域的人会被漏掉;调小了,边界上的人会被反复计入。两条线才能同时要。

7.2 一个调试时会用到的观察

跑起来你可能会看到”区域上写着 4,角落的统计写着 3”。这不是 bug。

区域上那个数是未去抖的原始值,角落那个是去抖之后的结果。两个数不一样,说明去抖正在工作——而且它能让你直观看到抖动的幅度有多大。

对外输出永远用去抖后的那个。

八、分水岭:人一多,前提就不成立了

前面全部的内容,都建立在一个前提上:

画面里每个人都能被单独检出,并且能持续跟踪。

这个前提在人多的时候会崩掉。两个人贴在一起,模型可能只给出一个框;三个人并排,NMS 可能把它们合并成一个。这时候无论把模型换多大都救不回来——不是模型不够强,是”一个人对应一个框”这个假设本身不成立了。

所以人数规模是一条硬边界,不是调参能跨过去的:

画面人数有效方案输出性质能拿到个体轨迹吗
< 20检测 + 跟踪 + ROI精确人数✅
20 ~ 100同上,但需抗遮挡(ReID)精确人数,准确率明显下降⚠️ ID 会跳
> 100换路线:人头检测 / 密度图回归估计值❌

密度图:一条完全不同的路

超过 100 人的场景(广场、车站、演唱会),主流做法叫密度图回归。

它根本不检测个体。网络直接输出一张热力图——每个像素的值代表”这个位置大概有多少人”,把整张图的值加起来就是总人数。

传统检测:  图 → [框1, 框2, ..., 框37] → 数一数 = 37
密度图:    图 → [热力图] → 求和 = 37.4

代价很清楚:你只知道”这块区域大概 37 个人”,不知道谁是谁。 拿不到轨迹,做不了进出流量,看不清个体,也没法画框。

后端的类比:这是从逐条记录的 OLTP 换成了预聚合的 OLAP 近似查询。给你的是”约 37”,换来的是在超大基数下还能跑得动。但你失去了行级别的粒度。

这条边界的实际意义:如果将来场景从会议室换到广场,那不是”调调参数”,是换一套算法 + 换一个标注数据集 + 重新训练。成本量级完全不同,所以场景确认要在动手之前。

九、选型:库、模型,以及一条许可证红线

9.1 库

库干什么协议备注
supervision检测后处理、区域判定、跟踪、渲染MIT ✅模型无关,可以当骨架
ultralyticsYOLO 训练与推理AGPL-3.0 ⚠️生态最好
RF-DETR检测模型Apache-2.0 ✅商用干净的替代
boxmot9 种跟踪器实现AGPL-3.0只当参考
PaddleDetection百度 PP-Human,含实时人流计数Apache-2.0中文文档,偏重

9.2 一条许可证红线

Ultralytics 的 YOLO 系列是 AGPL-3.0。 这比 GPL 更狠的地方在于:通过网络提供服务也算分发。

按 Ultralytics 官方许可页面的口径,下面这些情况都需要买商业授权:

  • 公司内部的业务工具、内部私有应用
  • 任何商业产品或服务
  • 专有 / 闭源软件
  • SaaS 平台、API、云服务
  • 嵌入硬件、边缘设备、机器人、摄像头
  • 商用场景下自行训练或微调的模型

而且——你用 YOLO 训练出来的权重,也是 AGPL 的。 按官方的说法,这个许可证覆盖训练代码,以及由该训练代码产出的模型。

也就是说,“我拿 YOLO 训一个模型,烧进硬件盒子里卖”这条路上,按官方口径你得把整个衍生作品开源,包括配置脚本。合规的做法只有两条:

  1. 买 Ultralytics 企业授权(按组织规模报价)
  2. 换成 Apache-2.0 / MIT 的模型

这里有个重要的澄清:MVP 阶段、只在自己电脑上跑验证,不分发就不触发开源义务,用 YOLO 完全没问题。

但迁移成本是随时间上涨的——所以从第一天起就把检测器封装成一个可替换的接口(就是附录里 detect() 那几行),换模型时只改一个函数。

十、常见误区

❌「数一下框的个数就是人数」 框会重复(NMS 没杀干净)、会漏(置信度抖动)。必须接跟踪,用稳定的 ID 来计数。

❌「用框中心点判断在不在区域内」 见第六节。斜俯视视角下,框中心在胸口高度,和”人站在哪”差着半个人的距离。用 bbox 底边中点。

❌「去抖就是把阈值调大一点」 单阈值怎么调都不对:调大门槛会让快速穿过区域的人被漏掉,调小又压不住边界抖动。而且数字会跳有三个根源,调阈值只压得住其中一个。必须用双阈值滞回。

❌「tracker_id 是稳定的,一个人从头到尾就一个 ID」 遮挡、两人交叉、出画再入画,ID 都会变。ID 一变,计数就会先减后加。 这是区域人数统计里最难彻底消除的误差来源,也是为什么人多时需要带外观特征的跟踪器(ReID)。

❌「人多了就换个更大的模型」 跨过 100 人这条线,要换的是算法路线(检测 → 密度图),不是模型尺寸。人贴人时两个框合成一个,换最大的模型也救不了——那是假设失效,不是能力不足。

❌「开源项目拿来就能商用」 见 9.2。AGPL-3.0 会传染到你的整个产品,包括你训练出来的权重。

❌「只输出数字就没有隐私问题了」 方向对,但要注意:不要顺手把原始视频落盘。只输出计数、不保存画面,是这个场景在隐私上能站得住脚的前提。

十一、自检清单

读完这篇,如果下面每一条你都能答上来,这条链路你就算走通了:

  • 这条链路上有哪六步?各自一句话是什么?
  • 模型 / 训练集 / 验证集 / 测试集 / 推理,分别对应做菜这件事里的哪个环节?串起来是个什么过程?
  • “训练”具体在做什么?为什么说你不需要自己训练?
  • 检测这一步的产物是什么?它为什么自己不够用?
  • 同一个人为什么会被检出多个框?谁在收拾?
  • NMS 留下的那个”小尾巴”是什么?它后来导致了什么问题?
  • 跟踪解决了检测留下的什么问题?tracker_id 在后端里对应什么概念?
  • ByteTrack 的”两阶段关联”多做了什么?为什么它有效?
  • 区域判定为什么不能用框中心?两个独立项目在这件事上的选择是什么?
  • 数字会跳的三个根源,分别来自前面哪一步?为什么调阈值治不了本?
  • 去抖为什么必须用两条线?单阈值怎么调都不对的原因是什么?
  • “区域上写着 4、角落写着 3”是 bug 吗?为什么?
  • 这条链路在什么情况下整条失效?那时候该换什么?

附:最小可跑实现

这一节是给想动手的人准备的。只想搞懂链路的话,到这里就可以结束了。

点开看完整代码(区域点选工具 + 去抖逻辑 + 主循环)

先划区域:一个鼠标点选的小工具

区域坐标手填很不现实。花 40 行写个点选工具,一劳永逸:

# pick_zones.py —— 在视频首帧上用鼠标划出区域,存成 JSON
import json

import cv2
import numpy as np


def pick_zones(video_path, out_path="zones.json"):
    """左键加点,右键撤销,回车闭合一个区域,q 保存退出。"""
    cap = cv2.VideoCapture(video_path)
    ok, frame = cap.read()
    cap.release()
    if not ok:
        raise SystemExit("读不到视频首帧")

    points, zones = [], []
    window = "pick zones"

    def on_mouse(event, x, y, _flags, _param):
        if event == cv2.EVENT_LBUTTONDOWN:
            points.append((x, y))
        elif event == cv2.EVENT_RBUTTONDOWN and points:
            points.pop()

    cv2.namedWindow(window)
    cv2.setMouseCallback(window, on_mouse)

    while True:
        canvas = frame.copy()
        if len(points) > 1:
            cv2.polylines(canvas, [np.array(points)], False, (0, 255, 0), 2)
        for p in points:
            cv2.circle(canvas, p, 5, (0, 0, 255), -1)
        cv2.putText(canvas,
                    f"points={len(points)}  ENTER=close zone  q=save&quit",
                    (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2)
        cv2.imshow(window, canvas)

        key = cv2.waitKey(20) & 0xFF
        if key == 13 and len(points) >= 3:          # 回车:闭合当前区域
            zones.append({"name": f"Zone{len(zones) + 1}", "polygon": points})
            points = []
        elif key == ord("q"):
            break

    cv2.destroyAllWindows()
    with open(out_path, "w", encoding="utf-8") as f:
        json.dump({"zones": zones}, f, ensure_ascii=False, indent=2)
    print(f"已保存 {len(zones)} 个区域到 {out_path}")


if __name__ == "__main__":
    pick_zones("meeting_room.mp4")

产出长这样:

{
  "zones": [
    { "name": "Zone1", "polygon": [[120, 300], [900, 300], [900, 700], [120, 700]] }
  ]
}

💡 划区域时,请把顶点对着人物的脚部落点位置来画,而不是对着桌子的视觉边缘。因为判定用的是脚点,两者视角不同。

去抖逻辑

# debounce.py
from dataclasses import dataclass

import numpy as np
import supervision as sv


@dataclass
class TrackState:
    """单个跟踪目标相对某个区域的状态。"""
    inside_streak: int = 0      # 连续在区内的帧数
    outside_streak: int = 0     # 连续在区外的帧数
    confirmed: bool = False     # 去抖后的最终判定:算不算 "在区域里"
    last_frame: int = 0         # 最后一次出现是第几帧


class DebouncedZone:
    """带滞回去抖的区域计数器。

    enter_frames: 连续多少帧在区内,才确认"进入"
    exit_frames:  连续多少帧在区外,才确认"离开"
    ttl:          目标消失多少帧后清理它的状态
    """

    def __init__(self, polygon, enter_frames=5, exit_frames=5, ttl=30):
        self.zone = sv.PolygonZone(polygon=np.asarray(polygon, dtype=np.int32))
        self.enter_frames = enter_frames
        self.exit_frames = exit_frames
        self.ttl = ttl
        self.states: dict[int, TrackState] = {}

    def update(self, detections, frame_idx: int) -> int:
        # 只要原始布尔判定;zone.current_count 是未去抖的,不能用
        raw_mask = self.zone.trigger(detections)

        if detections.tracker_id is None:
            return self._count()          # 还没接跟踪器,直接返回当前计数

        for track_id, is_in in zip(detections.tracker_id, raw_mask):
            state = self.states.setdefault(int(track_id), TrackState())
            state.last_frame = frame_idx

            if is_in:
                state.inside_streak += 1
                state.outside_streak = 0
            else:
                state.outside_streak += 1
                state.inside_streak = 0

            if not state.confirmed and state.inside_streak >= self.enter_frames:
                state.confirmed = True
            elif state.confirmed and state.outside_streak >= self.exit_frames:
                state.confirmed = False

        self._evict(frame_idx)
        return self._count()

    def _count(self) -> int:
        return sum(s.confirmed for s in self.states.values())

    def _evict(self, frame_idx: int) -> None:
        """清理长时间没出现的目标。

        不做这一步,人数会只增不减 —— 见下面「`_evict` 为什么不能省」。
        """
        expired = [tid for tid, s in self.states.items()
                   if frame_idx - s.last_frame > self.ttl]
        for tid in expired:
            del self.states[tid]

_evict 为什么不能省

这是最容易漏掉的一段,漏了会出一个很难查的 bug。

问题:一个原本被确认为”在区内”的人走出了画面。跟踪器找不到他,就不再返回他的 tracker_id 了。

但我们的 states 里,那条 {track_id: confirmed=True} 的记录还在。没人来把它改成 False——因为没人再触发它了。

结果就是:这个人走了,数字不减。 下一个人进来,数字继续加。跑一下午,人数能涨到几百。

回到第五节那个类比:这就是会话没有超时。 你维护了一个 Map<sessionId, Session>,客户端断开了、不再发心跳,但你的 Map 里那条记录永远留着。内存泄漏和统计虚高是同一个根因。

ttl 就是 session timeout。

主循环

# count_people.py
import json

import cv2
import numpy as np
import supervision as sv
from ultralytics import YOLO

from pick_zones import pick_zones          # 上面那个工具
from debounce import DebouncedZone         # 上面的 DebouncedZone

VIDEO = "meeting_room.mp4"      # 填 0 则用本机摄像头
ZONES = "zones.json"
PERSON_CLASS_ID = 0             # 数据集里「人」是第 0 类


def detect(frame, model):
    """检测这一步。封装成一个函数,方便日后换模型。"""
    result = model(frame, imgsz=640, conf=0.35, classes=[PERSON_CLASS_ID],
                   verbose=False)[0]
    return sv.Detections.from_ultralytics(result)


def main():
    if not __import__("os").path.exists(ZONES):
        pick_zones(VIDEO, ZONES)

    with open(ZONES, encoding="utf-8") as f:
        zone_cfgs = json.load(f)["zones"]

    zones = [DebouncedZone(z["polygon"], enter_frames=5, exit_frames=5, ttl=30)
             for z in zone_cfgs]

    model = YOLO("yolo11n.pt")
    tracker = sv.ByteTrack()
    box_annotator = sv.BoxAnnotator()
    zone_annotators = [
        sv.PolygonZoneAnnotator(zone=z.zone, color=sv.Color.GREEN,
                                display_in_zone_count=True)
        for z in zones
    ]

    cap = cv2.VideoCapture(VIDEO)
    frame_idx = 0

    while True:
        ok, frame = cap.read()
        if not ok:
            break

        detections = detect(frame, model)
        detections = tracker.update_with_detections(detections)   # 必须先于区域判定

        counts = [z.update(detections, frame_idx) for z in zones]

        for annotator in zone_annotators:
            frame = annotator.annotate(frame)

        labels = [f"#{int(tid)} {conf:.2f}"
                  for tid, conf in zip(detections.tracker_id, detections.confidence)]
        frame = box_annotator.annotate(frame, detections, labels)

        for i, count in enumerate(counts):
            cv2.putText(frame, f"{zone_cfgs[i]['name']}: {count}",
                        (20, 90 + i * 45), cv2.FONT_HERSHEY_SIMPLEX,
                        1.3, (0, 255, 0), 3)

        cv2.imshow("vision-pe", frame)
        if cv2.waitKey(1) & 0xFF == ord("q"):
            break
        frame_idx += 1

    cap.release()
    cv2.destroyAllWindows()


if __name__ == "__main__":
    main()

跑起来你会看到:人身上有框和 ID、区域是多边形(上面画着未去抖的原始计数)、左上角是去抖后的计数。

📌 顺序不能颠倒:tracker.update_with_detections() 必须在 zone.update() 之前。区域判定依赖 tracker_id,没跟踪就没有 ID。

🐌 如果帧率不够,按这个顺序试:① imgsz 从 640 降到 480 ② 换 ONNX / OpenVINO 推理 ③ 不必每帧都推理——隔 N 帧跑一次检测,中间帧复用上次结果。

附:怎么验证去抖是对的

去抖逻辑是纯状态机,完全可以用构造的假数据测——不用模型,不用视频,毫秒级跑完。这比盯着视频看半天靠谱得多。

点开看三个测试
# test_debounce.py
import numpy as np
import pytest
import supervision as sv

from debounce import DebouncedZone

# 一个正方形区域:x 从 100 到 300,y 从 100 到 300
ZONE = np.array([[100, 100], [300, 100], [300, 300], [100, 300]])


def make_dets(track_ids, foot_y):
    """构造一批脚点在 (200, foot_y) 的检测框。

    框的底边中点 = ((x1+x2)/2, y2),这里固定 x1=180, x2=220, y2=foot_y。
    """
    boxes = [[180, y - 100, 220, y] for y in foot_y]
    return sv.Detections(
        xyxy=np.array(boxes, dtype=float),
        confidence=np.ones(len(track_ids)),
        class_id=np.zeros(len(track_ids), dtype=int),
        tracker_id=np.array(track_ids, dtype=int),
    )


def test_边界抖动不改变计数():
    """人在边界线上反复进出 —— 这是最常见的抖动场景。"""
    zone = DebouncedZone(ZONE, enter_frames=5, exit_frames=5)

    # 先稳稳地在区内确认下来(y=200 在区域内)
    for i in range(10):
        assert zone.update(make_dets([1], [200]), i) == 1

    # 现在让脚点在边界 y=100 附近来回横跳
    for i in range(10, 60):
        y = 97 if i % 2 else 103        # 97 在区外,103 在区内
        count = zone.update(make_dets([1], [y]), i)
        assert count == 1, f"第 {i} 帧计数跳成了 {count}"


def test_快速穿过不计入():
    """只在区内待了 2 帧(< enter_frames)的人,不该被计数。"""
    zone = DebouncedZone(ZONE, enter_frames=5, exit_frames=5)
    for i in range(10):                              # 一直在区外
        assert zone.update(make_dets([1], [50]), i) == 0
    for i in range(10, 12):                          # 快速穿过,只有 2 帧
        assert zone.update(make_dets([1], [200]), i) == 0
    for i in range(12, 30):                          # 又出去了
        assert zone.update(make_dets([1], [50]), i) == 0


def test_目标消失后状态被清理():
    """不做 _evict 就会挂在这个测试上 —— 人数只增不减。"""
    zone = DebouncedZone(ZONE, enter_frames=5, exit_frames=5, ttl=30)
    for i in range(10):
        assert zone.update(make_dets([1], [200]), i) == 1

    # 人走了:跟踪器不再返回这个 ID,传空检测
    empty = sv.Detections.empty()
    empty.tracker_id = np.array([], dtype=int)
    for i in range(10, 50):
        count = zone.update(empty, i)
    assert count == 0, "目标消失后没有清理,出现了幽灵计数"

跑 pytest,三个全绿,去抖逻辑就是对的。

这三个测试里,第三个最容易在真实项目里被漏掉——因为它的症状(数字缓慢虚高)要在跑很久之后才明显。

附:延伸阅读

相关资料

资源说明
supervision 文档PolygonZone、ByteTrack、各类 Annotator 的权威说明
supervision 的 count_people_in_zone 示例和本文场景几乎逐字对应的官方实现
Frigate — Zones 文档区域判定与 inertia / loitering_time 的实战口径
ByteTrack 论文两阶段关联的原始出处
Ultralytics 许可说明AGPL-3.0 与商用授权的边界,本文 9.2 节的依据
RF-DETRApache-2.0 的检测模型,AGPL 问题的解法之一
CSRNet / 密度图回归密集人群计数的另一条路线

关联笔记


最后更新:2026-10-16