ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

零售客流统计实战:YOLOv11多目标跟踪与热力图生成技术详解

零售客流统计实战:YOLOv11多目标跟踪与热力图生成技术详解 简介这份PDF文档面向零售行业技术人员、计算机视觉初学者及希望落地智能客流分析的开发者系统讲解如何用YOLOv11实现多目标跟踪并生成店内热力图。内容从零售客流统计的重要性与现有方案局限切入逐步展开YOLOv11骨干网络、颈部网络与检测头架构解析基于检测的跟踪方法与目标关联算法以及客流统计指标计算和实际应用案例。文档还覆盖核密度估计、插值方法、颜色映射等热力图数学原理并给出基于密度估计与网格划分两种生成路径最后通过小型便利店、中型超市、大型购物中心三类实战项目串联系统搭建、性能优化与效果评估。资源包为1个PDF文件大小约2.03MB共33页支持目录章节跳转、阅读器左侧大纲显示与章节快速定位图表目录显示正常。已有114人学习适合需要完整掌握从检测跟踪到热力图可视化全流程的读者参考。1. 从一段翻车的门店视频说起这套 YOLOv11 客流方案到底能干什么去年帮一个连锁便利店做数字化改造老板指着监控室说“我有 16 路摄像头每天进多少人、哪个货架没人看全靠店长拿本子记。”我调了一周的视频回放发现最麻烦的不是检测不到人而是同一个人被反复计数、遮挡后 ID 跳变、热力图糊成一团。后来拿到这份《零售业客流统计实战YOLOv11多目标跟踪与热力图生成技术详解》33 页的体量把检测、跟踪、计数、热力图这条链路完整串了起来才意识到问题不在算法本身而在工程细节。这份文档面向的是真正要把系统跑起来的开发者不是讲 YOLO 发展史的科普。它从 YOLOv11 的骨干网络、颈部网络、检测头拆到多目标跟踪的匈牙利匹配与卡尔曼滤波再落到客流统计指标计算和核密度估计热力图生成最后给了一套完整的系统搭建流程。适合两类人一类是手里有门店视频、想做出可交付客流看板的工程师另一类是做计算机视觉项目、需要把检测跟踪串成业务闭环的开发者。如果你只想要一个能跑通的 demo文档里的代码片段够用如果你要上线第四、六、七章的架构和优化部分才是重点。2. YOLOv11 检测与多目标跟踪从单帧框到连续轨迹2.1 为什么客流统计必须用「检测跟踪」而不是纯检测纯目标检测每帧独立输出边界框帧与帧之间没有身份关联。放在客流统计场景里这意味着同一个人在画面里停留 10 秒、被检测到 300 次系统会认为来了 300 个人。更麻烦的是你无法判断这个人是进店还是出店因为检测结果里没有运动方向信息。多目标跟踪MOT解决的就是这个问题。它的核心思路是先检测再关联。每一帧用 YOLOv11 把画面里的人框出来然后通过关联算法把当前帧的框和上一帧的轨迹匹配上给每个目标分配一个稳定的 ID。有了 ID才能算停留时长、运动轨迹、进出方向。文档第三章把这条链路讲得很清楚基于检测的跟踪方法Tracking-by-Detection是目前工程上最成熟的方案YOLOv11 负责检测精度和速度跟踪算法负责时序一致性。这里有个选型上的取舍YOLOv11 相比前代在骨干网络里引入了通道注意力机制对小目标和遮挡场景的检测更稳。零售门店里货架遮挡、灯光变化是常态检测漏一帧跟踪就可能断轨。所以检测端的稳定性直接决定跟踪质量这也是为什么文档花了大篇幅拆解 YOLOv11 的 Backbone、Neck 和 Head。2.2 YOLOv11 检测模块的代码落地与参数说明文档里给了一个简化的通道注意力模块实现这是 YOLOv11 骨干网络的关键组件之一。我把它整理成可以直接跑的版本并补上参数注释import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, in_planes, ratio16): super(ChannelAttention, self).__init__() # 全局平均池化和全局最大池化分别提取两种统计特征 self.avg_pool nn.AdaptiveAvgPool2d(1) self.max_pool nn.AdaptiveMaxPool2d(1) # 两层 1x1 卷积构成瓶颈结构ratio 控制压缩比例 self.fc1 nn.Conv2d(in_planes, in_planes // ratio, 1, biasFalse) self.relu1 nn.ReLU() self.fc2 nn.Conv2d(in_planes // ratio, in_planes, 1, biasFalse) self.sigmoid nn.Sigmoid() def forward(self, x): avg_out self.fc2(self.relu1(self.fc1(self.avg_pool(x)))) max_out self.fc2(self.relu1(self.fc1(self.max_pool(x)))) out avg_out max_out return self.sigmoid(out) # 输出通道权重范围 0~1这段代码的逻辑是对输入特征图分别做平均池化和最大池化得到两个 1x1 的通道描述向量经过共享的两层全连接用 1x1 卷积实现后相加再通过 Sigmoid 归一化成通道权重。in_planes是输入通道数ratio默认 16表示压缩比通道数越大这个值可以适当调大以控制参数量。实际使用时这个权重会乘回原始特征图让网络自动关注信息量大的通道。检测部分调用预训练模型的代码文档里也给了我补上推理后的处理逻辑import torch from yolov11.models import YOLOv11 # 加载预训练权重pretrainedTrue 会自动下载官方权重 model YOLOv11(pretrainedTrue) model.eval() # 推理模式关闭 dropout 和 BN 更新 # 输入尺寸 640x640 是 YOLOv11 的默认训练分辨率 image torch.randn(1, 3, 640, 640) with torch.no_grad(): # 推理不需要梯度省显存 output model(image) # output 通常包含边界框坐标、置信度、类别概率 # 实际项目中需要接 NMS 后处理过滤重叠框参数上要注意输入分辨率 640 是精度和速度的平衡点门店场景如果摄像头架得高、人占像素少可以提到 1280但帧率会掉。pretrainedTrue加载的是 COCO 预训练权重person 类本身就在 COCO 里所以不用重新训练就能检测人。如果门店有特殊需求比如区分员工和顾客才需要在自己的数据上微调。2.3 卡尔曼滤波与匈牙利匹配让 ID 不跳变检测出框之后跟踪的核心是两件事预测和匹配。卡尔曼滤波负责预测目标在下一帧的位置匈牙利算法负责把预测位置和实际检测框对应起来。文档里给了卡尔曼滤波的 OpenCV 实现import cv2 import numpy as np # 创建卡尔曼滤波器4 个状态量x, y, vx, vy2 个观测量x, y kalman cv2.KalmanFilter(4, 2) # 观测矩阵只观测位置不观测速度 kalman.measurementMatrix np.array([[1, 0, 0, 0], [0, 1, 0, 0]], np.float32) # 状态转移矩阵匀速运动模型x x vx, y y vy kalman.transitionMatrix np.array([[1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0], [0, 0, 0, 1]], np.float32) # 过程噪声协方差值越大越相信观测越小越相信预测 kalman.processNoiseCov np.array([[1e-2, 0, 0, 0], [0, 1e-2, 0, 0], [0, 0, 5e-3, 0], [0, 0, 0, 5e-3]], np.float32) # 观测噪声协方差值越大越不相信检测框 kalman.measurementNoiseCov np.array([[1e-1, 0], [0, 1e-1]], np.float32) # 模拟一次观测更新和预测 measurement np.array([[100], [200]], np.float32) kalman.correct(measurement) prediction kalman.predict() print(预测位置:, prediction)这里的关键参数是processNoiseCov和measurementNoiseCov。前者表示运动模型的不确定性门店里顾客走走停停这个值可以适当调大让滤波器更相信检测结果后者表示检测框的噪声如果 YOLOv11 在遮挡时框抖动大就调大这个值。文档里给的是一组经验值实际部署时建议先用一段视频跑一遍看 ID 切换次数再微调。匈牙利匹配负责把预测框和检测框配对。相似度通常用 IoU交并比加外观特征的余弦距离。文档提到可以结合多种相似度指标综合判断我的经验是IoU 负责空间匹配外观特征负责遮挡后的重识别。如果只靠 IoU两个人交叉走过之后 ID 必换加上外观特征才能把断掉的轨迹接回来。3. 客流统计指标计算从轨迹到业务数字3.1 虚拟检测线怎么画才不数错人客流统计最基础的指标是进店人数和出店人数。文档给的方案是在出入口设置虚拟检测线当目标轨迹穿过检测线时判断方向并计数。听起来简单但实际部署时检测线的位置和方向判断逻辑直接决定准确率。我一般会把检测线画在门框内侧 20 到 30 厘米处而不是正对门口。原因是门口区域人员密集、遮挡严重检测框容易合并或丢失。往店内挪一段距离画面里人的间距拉开跟踪更稳。方向判断用轨迹的 y 坐标变化如果目标中心点从检测线一侧移动到另一侧且位移超过一个阈值比如 15 像素才判定为有效穿越。这个阈值是为了过滤掉在检测线附近徘徊导致的反复计数。文档里还提到店内停留人数的统计这个直接用当前帧的活跃轨迹数量就行。但要注意轨迹的生命周期管理新出现的轨迹要等连续几帧确认后才计入消失的轨迹要等若干帧未匹配后才移除。这两个参数确认帧数、丢失帧数在 DeepSORT 类算法里通常叫max_age和n_init门店场景建议n_init3、max_age30能有效减少 ID 碎片。3.2 停留时长和区域客流密度怎么算高级指标里顾客停留时长是分析购物行为的关键。计算方式是记录每个轨迹 ID 从首次出现到最终消失的帧数乘以帧间隔时间。但这里有个坑如果顾客走出画面再回来会被当成两个 ID停留时长就断了。解决办法是在跟踪器里维护一个已消失轨迹的缓存新轨迹出现时先和缓存做外观匹配匹配上就恢复原 ID。区域客流密度需要把画面划分成多个区域统计每个区域内轨迹点的数量。文档提到可以用网格划分或基于店铺地图的多边形区域。我倾向于用多边形因为货架和通道的形状不规则网格会把一个货架切成两半。实现上用 OpenCV 的pointPolygonTest判断轨迹中心点是否落在区域内import cv2 import numpy as np # 定义货架区域多边形按实际店铺地图标注 shelf_polygon np.array([[100, 200], [400, 200], [400, 500], [100, 500]], np.int32) def is_in_shelf(point): # point 是 (x, y) 元组 result cv2.pointPolygonTest(shelf_polygon, point, False) return result 0 # 返回正数表示在内部 # 对每条轨迹的每个点做判断累计区域内的轨迹数参数上多边形的顶点坐标需要根据摄像头画面做透视变换后标注。如果摄像头是斜装的直接按画面坐标画多边形会有偏差建议先用标定板做透视校正把画面映射到店铺平面图再标注区域。3.3 实时更新与多线程处理客流统计系统要求实时性文档提到可以用多线程或异步处理。我的做法是把检测跟踪和统计计算拆成两个线程检测线程负责跑 YOLOv11 和跟踪器把每帧的轨迹结果推到一个队列统计线程从队列取数据更新计数和区域密度。这样即使统计逻辑复杂也不会阻塞检测。队列长度要设上限比如 100 帧防止统计跟不上时内存暴涨。如果队列满了丢弃最旧的帧而不是最新的保证统计的是当前状态。这个细节文档没展开但实际部署时是必须处理的。4. 热力图生成从散点到可读的密度图4.1 核密度估计的带宽怎么选热力图的核心是把离散的顾客位置点转换成连续的密度分布。文档给的方案是核密度估计KDE公式里最关键的是带宽 h。带宽太小热力图会变成一个个孤立的亮点带宽太大整个图糊成一片看不出区域差异。我的经验是带宽取画面宽度的 1/20 到 1/10 之间。比如 1920 宽的画面带宽设 100 到 200 像素。如果店铺面积大、顾客分散取大值如果只想看货架级别的密度取小值。文档里用scipy.stats.gaussian_kde实现import numpy as np from scipy.stats import gaussian_kde # 假设已经收集到一小时内所有顾客的坐标点 x np.array([...]) # x 坐标列表 y np.array([...]) # y 坐标列表 data np.vstack([x, y]) # 创建 KDE 模型bw_method 控制带宽 # 传数值表示固定带宽传 scott 或 silverman 用自适应规则 kde gaussian_kde(data, bw_method0.15) # 生成网格点用于计算密度 x_grid, y_grid np.mgrid[0:1920:200j, 0:1080:200j] grid_coords np.vstack([x_grid.ravel(), y_grid.ravel()]) # 计算每个网格点的密度值 density kde(grid_coords).reshape(x_grid.shape)bw_method参数如果传数值表示带宽相对于数据标准差的倍数。传 0.15 意味着带宽是标准差的 15%。实际调参时先用默认的scott跑一遍看热力图是否过平滑再手动调小。4.2 颜色映射与叠加到店铺平面图密度算出来之后需要映射成颜色。文档提到用matplotlib的cm模块常见方案是jet或hot。但零售场景我建议用jet的变体因为红色到蓝色的对比度高店长一眼能看出热点区域。import matplotlib.pyplot as plt import numpy as np # density 是上一步算出的密度矩阵 cmap plt.get_cmap(jet) norm plt.Normalize(vminnp.min(density), vmaxnp.max(density)) colors cmap(norm(density)) # 绘制热力图 plt.imshow(colors, interpolationbilinear, alpha0.6) plt.colorbar() plt.show()alpha0.6是透明度方便叠加到店铺平面图上。interpolationbilinear让颜色过渡更平滑避免网格感。如果要输出到 Web 端可以把密度矩阵转成 JSON前端用 heatmap.js 渲染文档第五章提到的 D3.js 和 Chart.js 也是可选方案。4.3 动态热力图的更新策略实时热力图不需要每帧重算 KDE那样计算量太大。我的做法是维护一个滑动窗口比如最近 5 分钟的轨迹点每隔 10 秒重算一次密度。窗口内的点按时间衰减加权越新的点权重越高这样热力图能反映当前客流分布而不是全天平均。衰减系数用指数衰减半衰期设 2 分钟。实现上给每个点乘一个权重exp(-lambda * (t_now - t_point))lambda由半衰期决定。这个细节文档没提但做实时看板时很关键否则热力图更新滞后店长看到的是一小时前的分布。5. 避坑与排查那些让项目返工的细节5.1 摄像头俯角太大导致检测框重叠现象画面里人稍微靠近就检测成一个框跟踪 ID 频繁合并。 原因摄像头安装高度不够或俯角太大人在画面里投影重叠。 解决摄像头建议装在 3 到 4 米高度俯角控制在 30 到 45 度。如果已经装好可以在检测后加一个基于框宽高比的过滤把异常宽的框拆开或丢弃。5.2 光照突变导致跟踪断轨现象门店开灯或关门时画面亮度突变跟踪 ID 大面积切换。 原因YOLOv11 对亮度变化敏感检测框位置抖动匈牙利匹配失败。 解决在检测前加直方图均衡化或自适应亮度归一化。另外把卡尔曼滤波的measurementNoiseCov调大让滤波器在检测抖动时更依赖预测。5.3 热力图带宽设太小导致满屏噪点现象热力图看起来像星空每个顾客位置一个亮点看不出区域趋势。 原因KDE 带宽设得太小或者轨迹点没有做去重。 解决带宽调到画面宽度的 1/10 以上。另外同一个顾客在同一个位置停留多帧轨迹点会重复累积需要按时间或距离做抽稀比如每 30 帧或每移动 50 像素才记录一个点。5.4 虚拟检测线计数偏多现象进店人数统计比实际多 20% 以上。 原因检测线附近人员徘徊轨迹反复穿越或者跟踪 ID 切换导致同一人计两次。 解决加穿越阈值位移超过 15 像素才计数加轨迹确认帧数新 ID 连续 3 帧才计入加已计数 ID 缓存同一 ID 在短时间内不重复计数。5.5 服务器 GPU 显存不足现象跑几路视频后程序崩溃报 CUDA out of memory。 原因YOLOv11 模型加跟踪器的特征提取网络占用显存多路并发时累积。 解决限制每路视频的输入分辨率或者用批处理方式把多路视频拼成一个 batch 推理。如果还不行把外观特征提取网络换成轻量版或者降低跟踪器的max_age减少缓存轨迹数。6. 进阶技巧把热力图和客流指标串成经营看板系统跑通之后真正有价值的是把热力图和客流指标关联起来。我一般会做三件事第一把热力图按小时切片对比不同时段的区域热度变化找出哪些货架在特定时段被冷落第二把停留时长和区域密度做交叉分析密度高但停留短说明是通道密度高且停留长才是真正的兴趣区第三把进店人数和成交数据做关联算出门店转化率。验证方法上我会用人工计数做基准。选一个客流适中的时段安排一个人在门口手动计数同时跑系统统计对比误差。如果误差在 5% 以内说明检测跟踪链路没问题如果超过 10%先查检测线位置和穿越阈值再查跟踪 ID 切换次数。ID 切换次数可以在跟踪器里加一个计数器每发生一次 ID 切换就累加正常情况下一小时内切换次数应该低于进店人数的 10%。还有一个技巧是热力图的颜色映射不要用固定范围而是用当前时段的最大密度做归一化。这样不同时段的热力图对比时颜色深浅反映的是相对热度而不是绝对密度店长更容易看出变化趋势。从那以后我每次部署客流系统都会先在目标门店录一段 30 分钟的测试视频离线跑一遍完整链路把 ID 切换次数、计数误差、热力图带宽这三个参数调到位再上线。上线后第一周每天对比人工计数确认稳定后才交给运营。这套流程走下来返工率能降一大半。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进