有时候用户问的问题越简单,动手做的时候坑越多。前阵子我在迭代一版检测方案,模型用到的就是队里一直沿用的YOLO26内部版本,功能上已经能稳定输出边界框和类别了。结果下游同学过来问了一句:你能不能直接告诉我,这批测试视频里一共有多少辆车、多少人、多少只鸟?我下意识觉得这不就是把框的数量加一下吗?等真去实现“YOLO26+自动计数+自动统计各个类别数量”的时候才发现,检测和计数之间隔着一个状态管理问题,远不是一行sum能解决的。
这篇把整个方案的思考过程、模块设计、核心代码、实验指标和踩坑记录完整写出来。如果你正在做检测类的项目,或者想给论文/报告里的“工作量”加一个有说服力的扩展模块,可以直接按这套思路复现。它不需要改模型结构,也不需要重新训练,属于把检测结果变成业务统计结果的“最后一公里”。
1. 检测和计数之间隔着一个“状态管理”
很多刚接触检测任务的读者会以为“模型能输出框,那数量自然就出来了”。这个直觉对单张静态图成立,放到视频或长序列里就立刻破功。
1.1 从一张图到一段视频,YOLO26原生的输出缺了什么
YOLO26单帧推理拿到的一般是[N, 6]这样的数组,每一行对应一个检测框,包括框的坐标、置信度、类别编号。它解决的是“这张图里有没有目标、目标在哪儿、目标属于哪一类”这个问题。它不解决“这段视频里一共有多少个独立目标”这个问题。
差别在哪?模型天生没有跨帧状态。视频第10帧出现一辆车,第11帧同一辆车还停在那里,模型会再次输出一个检测框。如果不做任何处理,按帧累加就会把同一辆车数两遍。更别说一辆车在画面里停留30秒,按25帧每秒算,它可能被数了750次。这就是典型的“框有价值,直接加框没价值”。
所以要做自动计数,必须把“检测结果”和“统计口径”分开看。你的业务到底要的是“某一帧画面里同时出现的最大数量”“一个视频里出现过多少独立目标”还是“固定区域里进出了多少人”,统计逻辑完全不同。
1.2 先把“计数”拆成三类:逐帧统计、去重总数、区域断面计数
我建议在写代码之前先确认需求,否则后面返工的概率非常大。结合实际场景,最常见的计数需求其实是下面三种:
| 计数类型 | 统计口径 | 典型场景 | 最关键依赖 |
|---|---|---|---|
| 逐帧统计 | 每一帧图片中各类别目标的数量 | 考场人数统计、货架商品瞬时数量 | 只需要检测结果 |
| 去重总数 | 整段视频中互不重复的目标总数量 | 停车场车辆统计、养殖场动物盘点 | 需要跟踪ID或特征匹配 |
| 区域断面计数 | 越过某条线/进入某块区域的目标数量 | 出入口客流统计、高速卡口车流统计 | 需要跟踪ID + 空间判断 |
逐帧统计最简单,拿检测结果按类别做聚合就行。去重总数就复杂了,你需要知道“第10帧的这辆车”和“第15帧的这辆车”是不是同一辆。区域断面计数还要在这个基础上加上轨迹方向判断。
我在这个项目里的需求比较复合:既要统计视频中各类别出现的去重总数,也要输出每一帧的分布情况。所以我最终做的是一个可插拔的计数模块,既不碰YOLO26的训练流程,也不改它的权重,所有逻辑都挂在推理结果的后面。
2. 自动计数模块的总体设计与选型
这个模块我起名叫 AutoCounter,设计原则很简单:把计数从模型里剥离出来,做成一个独立组件。好处是模型升级了计数逻辑不用重写,业务口径变了模型也不需要重新训练。
2.1 不修改模型结构也能挂接计数功能
有些同学一看到“改进YOLO”就以为必须往网络里加分支、加注意力、加损失函数。其实大部分计数需求完全不需要动模型。检测模型负责感知,计数模块负责记忆和汇总,两者解耦之后反而更容易排查问题。
我们当时考虑过三种做法:
第一种是在模型输出层加一个回归头,直接预测目标数量。这个方案对单一类别且画面目标数量固定的场景或许有效,但可解释性差,也不方便扩展到多类别,而且需要重新训练。
第二种是训练一个单独的计数网络,比如密度图估计模型。这类模型适合人群密度估计,但照样需要自己准备数据、训练、调参,周期很长。
第三种就是本文说的后处理计数方案。检测器直接复用现成的YOLO26能力,后面挂计数器,通过跟踪ID完成去重。这种方案实现最快,也最容易写清楚逻辑,非常适合作为工程落地或论文中的“附加模块”。
从结果看,第三种确实是最划算的。它把检测精度和计数精度解耦:检测不准,锅归检测;计数逻辑有Bug,单独修计数逻辑就行。实验时还能分别报mAP和计数指标,论文的工作量也丰富不少。
2.2 类别字典、跟踪ID和ROI规则三者如何协作
自动统计各个类别数量,前提是有一个“类别字典”。这个字典直接来自训练用的类别配置文件,不要在代码里硬编码["cat", "dog"]这种列表。因为数据集一旦增加类别,硬编码列表就会漏统计,而且排查起来非常隐蔽。
跟踪ID是整个模块最核心的字段。它解决的是“同一目标出现多次,如何只算一次”的问题。如果推理时没有跟踪ID,也可以先用交并比匹配相邻两帧的框,自己维护一套简易ID。不过自己写匹配逻辑容易在目标密集或遮挡时把ID弄丢,更省心的方式是直接开启检测器配套的跟踪链路。
ROI规则属于可选项。如果业务不需要按区域计数,完全可以不接。它起的作用是空间过滤:只统计画面中某个多边形区域内部的目标。比如只统计货架区域内的人,忽略走廊上走动的人。
这三样东西协作起来后,整个计数流程才真正闭环:先过滤出ROI内的检测框,再按类别聚合,最后用跟踪ID去重。
3. 核心代码:把检测结果变成可复用的统计流
下面这段代码是我实际项目里简化后的版本,把复杂的工程细节去掉,保留核心逻辑。它分为三部分:检测结果包装、计数器主体、调用示例。
3.1 推理结果先转成统一的检测对象
YOLO26推理返回的原始数组直接用起来不方便,我习惯先包一层TrackedDet对象,把类别、坐标、置信度、跟踪ID都放在一个结构里,后续所有模块都用这个结构传数据。
class TrackedDet: __slots__ = ("cls", "score", "bbox", "track_id") def __init__(self, cls, score, bbox, track_id=None): self.cls = cls self.score = score self.bbox = bbox # [x1, y1, x2, y2] self.track_id = track_id @property def center(self): return ( (self.bbox[0] + self.bbox[2]) / 2, (self.bbox[1] + self.bbox[3]) / 2 )包装这一步的价值在于:后面无论换什么检测器,只需要把新模型的输出适配成TrackedDet,计数器代码完全不用改。以后YOLO26再升级,或者换成另一套检测框架,迁移成本都极低。
3.2 按类别累计与去重逻辑
计数器主体维护两个状态:total是当前累计的各类别总数,_seen是已经计过数的跟踪ID集合。每次新进来一批检测框,如果带跟踪ID,就先去重;如果不带跟踪ID,就按逐帧累加处理。
class AutoCounter: def __init__(self, class_names): self.class_names = class_names self.total = {name: 0 for name in class_names} self._seen = set() self.frame_idx = 0 self.frame_stats = [] def reset(self): self.total = {name: 0 for name in self.class_names} self._seen.clear() self.frame_idx = 0 self.frame_stats = [] def update(self, dets, dedup=True): self.frame_idx += 1 per_frame = {name: 0 for name in self.class_names} for det in dets: if det.cls not in self.total: continue per_frame[det.cls] += 1 if not dedup: self.total[det.cls] += 1 continue if det.track_id is None: self.total[det.cls] += 1 continue if det.track_id in self._seen: continue self._seen.add(det.track_id) self.total[det.cls] += 1 self.frame_stats.append({ "frame": self.frame_idx, "per_frame": per_frame }) return self.total.copy()这里有个细节值得展开:当dedup=True时,ID去重后的总数只会在目标第一次出现时加一次,之后目标被持续检测到,total不再增加。但per_frame反映的是当前帧的实际可见数量,两个口径用途不同。比如统计停车场总车辆数要用去重总数,监控画面里同时排队的人数要用逐帧统计,千万别混用。
3.3 实时调用和周期输出
实际跑视频时,只需要循环读帧、推理、更新计数器即可。下面这段是调用端的示例:
counter = AutoCounter(["cat", "dog", "bird", "car"]) for frame in video_stream: raw_results = yolo26_infer(frame) dets = [TrackedDet( cls=raw["class_name"], score=raw["score"], bbox=raw["bbox"], track_id=raw.get("track_id") ) for raw in raw_results] counter.update(dets, dedup=True) if counter.frame_idx % 100 == 0: print(f"第 {counter.frame_idx} 帧: {counter.total}")每一百帧打印一次累计结果,方便人工核对。最后一帧处理完后,counter.total就是整段视频的去重后类别数量。如果你还要统计ROI区域,只需要在counter.update之前过滤一次:
def is_inside(bbox, roi): cx = (bbox[0] + bbox[2]) / 2 cy = (bbox[1] + bbox[3]) / 2 return roi.contains(cx, cy) def filter_inside(dets, roi): return [det for det in dets if is_inside(det.bbox, roi)]中心点落在ROI多边形内就保留,否则丢掉。要注意的是,这个过滤逻辑只能做区域内部目标统计,不能据此判断目标是否“进入区域”。想统计进出流量,还得结合跟踪ID和相邻帧中心点的位移方向。
4. 用实验设计和图表把“工作量”做实
很多做检测改进的同学会把精力全放在mAP上,做完发现论文里只有一张结果表,显得很单薄。挂上计数模块之后,实验维度一下子变多了,而且都是真实可复现的指标,不是凭空造数据。
4.1 计数类任务建议增加的指标
检测模型能力之外,计数模块本身可以单独出指标。比较常用的是三个:
| 指标 | 计算公式 | 含义 |
|---|---|---|
| MAE | mean( | 预测计数 - 真实计数 |
| MAPE | mean( | 预测计数 - 真实计数 |
| 计数准确率 | 1 - MAE / 真实计数 | 计数任务中最直观的准确率 |
计算方式不复杂,但需要说明的是“真实计数”怎么定义。如果统计的是去重总数,那人工标注也要按同一个口径来。否则会出现模型数了8辆车、人工数了3次进出同一辆车然后标了3辆车的尴尬情况,指标无意义。
我当时的做法是单独标了一组测试序列,每个序列给一个“期望正确总数”标签,再跑计数模块,统计 MAE 和 MAPE。这个指标可以和原始YOLO26的检测指标放在一张大表里,展示改进模块不会牺牲检测精度的同时,还额外输出了业务价值。
4.2 可视化方案:时间线、堆叠柱状图、热力分布
除了数字指标,一张好的图能让人一眼看清计数模块在干什么。
第一张图建议画“每帧各类别数量折线图”。横轴是帧序号,纵轴是目标数量,每条曲线对应一个类别。它能反映一天中或一段视频中各类别出现的波动趋势。
第二张图是“类别堆叠柱状图”。把每个时间段内的去重总数按类别堆起来,适合做整段时间的类别结构分析。
第三张图是“ROI热力图”。把检测框中心点落在画面中的位置统计成密度热力图,能直观看出哪些区域目标最集中。
这三张图不用刻意美化,用常见的绘图库就能画得很好看。对论文来说,“框体可视化”只是展示模型看到了什么,而“统计图表”展示的是模型输出如何被转化成了决策信息。两者配合,工作量看起来完整得多。
4.3 论文怎么写这部分才不突兀
计数模块在论文里的正确位置是“方法”章节里作为后处理分支出现,或者在实验章节作为扩展分析。不要把它包装成检测器主干创新,那会很牵强,审稿人一眼就能看穿。
一个比较自然的结构是:先描述YOLO26作为基础检测器,再提出“现有检测结果无法直接满足业务统计需求”的问题,然后给出一个后处理计数分支,包含跟踪ID去重和类别累计逻辑。实验部分增加计数指标,结论部分强调“不仅提精度,还输出结构化统计结果,对下游决策更友好”。
相比无中生有地加一堆模块,这种“检测+计数”的组合是真实需求驱动的,每一步都有明确的目的,论文也更好写。
5. 踩坑复盘:重复计数、类别失衡与阈值设置
这个模块写起来不难,真正让我花时间的是各种隐藏场景下的错误计数。下面几个坑是我实际踩过并且反复确认过的,直接写出来帮你省时间。
5.1 重复计数比漏计更可怕
漏计往往是因为目标被挡住或检测置信度低,相对容易被发现。重复计数则隐蔽得多,尤其当跟踪ID频繁切换时。目标被短暂遮挡后重新出现,跟踪模块可能认为它是个新目标,分配一个新ID,计数就会从1变成2。
我在项目中遇到过最夸张的情况:一辆车因为树荫遮挡导致ID切换了三次,总数直接多了2。解决思路有两个。一是提高跟踪模块的关联稳定性,允许目标在遮挡期间保留ID更久,不过这会引入计算开销。二是在统计层做“ID合并”补偿,如果两个ID的框位置高度重合且类别相同,就视为同一个目标。
实际操作中我选择的方案是前者:在进入计数模块之前,先对短时间中断的目标做轨迹插值。也就是目标消失了三五帧,就延续它原来的ID继续保持。只有超过阈值才认为它真正离开。
5.2 类别不均衡会造成计数虚高
如果数据集里某个类别数量极少,模型对该类别的置信度往往不稳定。比如“施工现场机械”只有几十个标注框,模型很容易把其他类别误检成机械。计数模块是把所有超过阈值的框都算进去的,一旦误检,错误就会被累计到总数里。
这时候不要急着调计数逻辑,先回头看看检测器在这个类别上的precision和recall。分类别统计的precision低,误检框自然多,计数自然虚高。可以按类别单独设置置信度阈值,比如普通类别用0.25,稀有类别提高到0.45,牺牲一点召回率换取计数可信度。
5.3 置信度阈值和IOU阈值之间的平衡
阈值这件事没有一个通用值,关键看你的业务容忍度。如果计数是为了做总量统计,稍微漏检几个目标影响不大,那么置信度阈值可以适当降低,把更多目标框进来。要是计数是为了精确核对库存或人员,漏检一个都会被追责,那就要把阈值调高,再配合跟踪模块保障重框目标只计一次。
检测的IOU阈值影响的是NMS之后保留的框数量。阈值设太低,同一目标会被拆成两个框;阈值设太高,密集场景下两个紧挨的目标会被合并成一个框。这两个方向都会让计数失真。我的经验是先按常见值 0.45 跑一遍,再看具体场景里的计数误差曲线,误差突然变大的帧数往往就是阈值敏感区间。
6. 一点个人体会:计数模块还能往哪延伸
从“YOLO26自动计数”这个点延伸出去,我后来还做了两个比较有用的变体。一个是时间片统计,按小时或按天聚合计数结果,得到一段趋势曲线,能够辅助判断高峰期。另一个是ROI密度预警,把ROI内目标数量超过阈值的时刻标记出来,做成异常告警。
这两个变体都没有重新训练模型,纯粹是在计数模块之外再加一层规则。过程让我意识到,模型性能之外的“封装能力”往往被严重低估。把每一帧的检测结果变成结构化统计,再把统计结果变成业务语言,很多问题不需要重新训练也能解决。以后你再听到“自动计数”这个需求,先别急着往模型里塞新模块,仔细问清楚统计口径,后面能少走不少弯路。