简介:基于KDE(核密度估计)与密度估计方法实现行人检测和行人追踪的MATLAB项目全套源码,面向刚入门及有一定基础的开发者,可直接用于学习计算机视觉中的目标检测与跟踪流程。压缩包共26个文件,其中24张jpg训练/测试帧图像包含不同时刻的连续帧,1个m源码文件为核心算法实现,另有1份docx文档说明项目结构、运行步骤与参数设置,整体仅7.6MB,轻量易部署。目前已有306人浏览学习,代码经作者亲测校正,百分百成功运行,下载后若遇到环境或运行问题还可联系作者指导。整个项目提供完整可执行代码、配套图像数据集与文字说明,既能帮助新手结合连续帧理解KDE密度估计原理和行人检测追踪的工程实现,也可作为有经验开发者二次开发、算法调优与结果对比的起点。
1. 为什么用 KDE 做行人检测与追踪:一张密度图解决两个任务
把行人检测和行人追踪同时做的思路其实很多,但 KDE(核密度估计,Kernel Density Estimation)这套密度估计方法常被忽略。它不需要你下载行人检测数据集去做训练,也不需要 GPU,仅凭 MATLAB 就能把固定摄像头画面里的行人位置“糊”成一张连续概率密度场:密度场的每个峰就是行人,峰在时间轴上的位移就是轨迹。于是检测与追踪不再分成两个独立模块,而是共用一张密度图,接口断层的麻烦直接少了一半。适合正在做算法原型、毕设或小型客流系统的工程师,尤其适合场景相对固定、行人数量不爆炸的监控区域。
2. KDE 与密度估计在行人检测里的角色:从像素帧到概率密度场
2.1 核密度估计的基本原理:用核函数把稀疏观测“糊”成连续曲面
核密度估计最早是统计学里估计随机变量概率密度的工具。给定一组观测样本,它在每个样本位置放一个核函数,再叠加归一化,就得到一条平滑的密度曲线。一维公式写出来是:
f(x) = 1 / (N·h) · Σ K((x - x_i) / h)
其中 N 是样本数,h 是带宽,K 是核函数。行人检测里不做一维,做二维:样本是“可能是行人的前景像素坐标 (x, y)”,核函数变成二维高斯核。二维形式等价于在每个坐标上叠一个小山包,山包叠多了就连成起伏的地形。这个地形就是密度估计场。
直方图也能做密度估计,但直方图有箱体边界,行人跨过箱体边界时密度值会跳变。KDE 的核函数是逐个样本叠加的,输出连续,峰值位置不容易受网格边界影响。这就是我做这个方案不选直方图反投影的原因。
MATLAB 里可以用ksdensity先感受一维效果,方便理解带宽的作用:
% 一维核密度估计示例:生成双峰数据,用 ksdensity 估计密度曲线 rng(42); x = [randn(500,1) + 5; randn(300,1) + 12]; [fi, t] = ksdensity(x, 'Bandwidth', 2); figure; plot(t, fi, 'LineWidth', 1.5); grid on; title('KDE Density Curve');rng(42)让随机序列可复现;Bandwidth是核函数的带宽,越大曲线越平滑,越小越容易保留局部起伏。图像里的二维 KDE 不建议直接用ksdensity,因为它是逐点计算,对一张 1080p 图像而言样本点数和网格点数都太大,会卡到怀疑人生。图像上的二维核密度估计有更快的工程做法,我放到第三章讲。
2.2 为什么选高斯核:带宽矩阵与多维密度估计的取舍
核函数类型很多:矩形核、Epanechnikov 核、高斯核。矩形核实现最简单,但密度场有硬边界,峰值不平滑;Epanechnikov 核理论效率高,但 MATLAB 里没有现成函数。我一般默认用高斯核,原因有三条。第一,高斯核光滑可微,后续做峰值检测时不容易出现局部毛刺。第二,两个高斯卷积还是高斯,做多尺度密度估计时性质稳定。第三,二维高斯核滤波等价于 MATLAB 的imgaussfilt,可以直接复用工具箱的加速实现,不用手写卷积循环。
多维密度估计的关键参数是带宽矩阵。二维场景下理论上要估计一个 2×2 矩阵,能表达椭圆形的分布。但工程上调 2×2 矩阵很难直观掌控,我通常直接简化成对角线矩阵,也就是 x 和 y 方向各一个标量带宽。如果行人的宽高比比较固定,再进一步简化成单一标量 h。带宽 h 决定密度图上峰的数量:h 太小,每个前景像素都成峰,噪声会被当成行人;h 太大,相邻行人峰融合,行人密度高时漏检严重。
从计算复杂度看,直接对 N 个样本点求 M 个网格点的密度是 O(N·M),处理一帧几万前景像素非常吃力。高斯核有一个特殊性质:频域相乘等价于时域卷积。所以可以把样本点先累积到计数网格上,再用 FFT 或者imgaussfilt做卷积,复杂度降到 O(M log M)。这也是这个方案能用 MATLAB 在非实时但可交互的速度下跑起来的核心原因。
3. 用 MATLAB 实现基于 KDE 的行人检测:从背景建模到检测框
3.1 第一步:背景差分或帧差提取运动前景
KDE 处理的是“哪些像素可能是行人”,所以第一步要产出一张前景 mask。我没有直接在原图上做密度估计,那样背景纹理也会形成大量假峰。常见做法是背景差分。MATLAB 的 Computer Vision Toolbox 里有一个vision.ForegroundDetector,内置混合高斯背景模型,能处理树叶晃动和光线渐变,比帧差鲁棒。
% 读取视频并创建前景检测器 vr = VideoReader('walkway.mp4'); fgDetector = vision.ForegroundDetector(... 'NumGaussians', 3, ... 'NumTrainingFrames', 30, ... 'MinimumBackgroundRatio', 0.7); % 逐帧读取并提取前景 mask frameIdx = 0; while hasFrame(vr) frame = readFrame(vr); fgMask = fgDetector(frame); % 逻辑型矩阵,1表示前景 frameIdx = frameIdx + 1; if frameIdx == 50 break; end endNumGaussians=3表示背景模型用 3 个高斯分量,能适应树枝晃动;NumTrainingFrames=30表示用前 30 帧训练背景模型;MinimumBackgroundRatio=0.7表示像素被判定为背景的最低概率阈值。如果摄像头有轻微抖动,建议先用imregister或者 ECC 配准稳像,否则大量边缘抖动量会被当成前景噪声送进 KDE,密度场会变花。
没有 Computer Vision Toolbox 时,退而求其次可以用帧差:
% 帧差法:保底方案,无额外工具箱依赖 prevGray = im2gray(frame); for idx = 1:200 frame = readFrame(vr); gray = im2gray(frame); diffMask = abs(gray - prevGray) > 25; prevGray = gray; % 形态学处理,填补身体内部空洞,滤除孤立噪点 diffMask = imopen(diffMask, strel('disk', 2)); diffMask = imclose(diffMask, strel('disk', 5)); end帧差法的问题是只能拿到行人的轮廓边缘,身体中间因为前后帧变化小形成空洞。但这个问题到 KDE 这层会被弱化:前景点虽然不完整,密度估计仍能把这些点聚成一个峰,只是峰的位置会偏向运动边缘的几何中心。所以帧差法更适合快速验证 KDE 流程,正式项目我还是建议用混合高斯背景。
3.2 第二步:把前景像素映射为密度场:二维 KDE 的 MATLAB 实现与参数选择
拿到前景 mask 后,把每个前景像素坐标当作一个“事件”,在网格上做核密度估计。为了计算量可控,不直接在原图分辨率做,而是把 mask 投到一个固定尺寸网格上。
% 输入:fgMask 逻辑矩阵,HxW [H, W] = size(fgMask); gridSize = [80, 120]; % 网格尺寸:高度80,宽度120 % 把前景像素坐标映射到网格坐标 [rows, cols] = find(fgMask); % rows对应y,cols对应x xBin = round((cols - 1) / (W - 1) * (gridSize(2) - 1)) + 1; yBin = round((rows - 1) / (H - 1) * (gridSize(1) - 1)) + 1; % 累积计数网格 densityGrid = zeros(gridSize); for k = 1:numel(xBin) densityGrid(yBin(k), xBin(k)) = densityGrid(yBin(k), xBin(k)) + 1; end % 二维高斯核估计:用高斯滤波等效叠加 sigmaGrid = 3.0; densityGrid = imgaussfilt(densityGrid, sigmaGrid, 'Padding', 'replicate');imgaussfilt在这里就是核密度估计的高斯核叠加过程。sigmaGrid=3是网格坐标系下的带宽,如果原图是 1080p,网格是 80×120,那一个网格宽约 16 像素,sigma=3 等效于在原图上约 48 像素的高斯平滑。具体值要看视频里行人的宽度,通常行人身体宽度在 30 到 60 像素时,网格 sigma 取 2 到 3 比较合适。
这个实现是“计数网格 + 高斯滤波”,和严格意义上的核密度估计有什么区别?严格做法要对每个样本用核函数在网格上直接赋值,耗时;而高斯滤波是对所有样本同时做核平滑,数学上是同一个线性叠加过程,前提是高斯核在网格上平移不变。网格足够密时误差可以忽略。
同样要注意,计数网格是 80×120,比原图小得多,所以内存占用很小。如果想在严格统计意义上归一化成概率密度,可以在最后除以网格面积和样本总数,但检测任务里我们只关注峰的位置,归一化并不影响峰值坐标。
3.3 第三步:峰值检测与行人定位:从密度图到 bounding box
密度图上行人就是局部极大值峰。用形态学膨胀找局部极大值,然后用阈值滤除低置信度峰。
% 阈值:最大峰值的20%以下不产生检测 thresh = 0.2 * max(densityGrid(:)); binaryMap = densityGrid > thresh; % 局部极大值:密度图与它的“局部膨胀结果”相等的点 maxMap = imdilate(densityGrid, strel('disk', 5)); peakMask = (densityGrid == maxMap) & binaryMap; [peakY, peakX] = find(peakMask); % 将峰值坐标映射回原图,并生成检测框 scaleX = W / gridSize(2); scaleY = H / gridSize(1); boxes = zeros(0, 4); for k = 1:numel(peakX) cx = (peakX(k) - 1) * scaleX + scaleX / 2; cy = (peakY(k) - 1) * scaleY + scaleY / 2; boxW = sigmaGrid * scaleX * 4; % 宽度:带宽换算到原图后×4 boxH = sigmaGrid * scaleY * 7; % 高度:按行人宽高比拉长 boxes(end+1, :) = [cx - boxW/2, cy - boxH/2, boxW, boxH]; %#ok<AGROW> endstrel('disk', 5)的半径决定了峰值检测邻域大小,半径太小会在一个行人身上出现多个峰,太大又会把紧密相邻的行人合并。thresh设为最大峰值的 20%,基本能滤掉背景残留的弱峰。检测框宽高用带宽经验倍数估算,后续还可以用行人身高先验修正。
这里缺少一步非极大值抑制,因为同一个密度峰在边缘位置可能出现两个相邻极大值。用一段贪心 NMS 可以去掉重叠框:
% 贪心非极大值抑制:按面积排序,滤掉IoU>0.5的重复框 [~, idx] = sort(boxes(:,3) .* boxes(:,4), 'descend'); keep = true(size(boxes,1), 1); for i = 1:size(boxes,1) if ~keep(idx(i)), continue; end for j = i+1:size(boxes,1) if ~keep(idx(j)), continue; end % 计算交并比IoU iou = computeIoU(boxes(idx(i),:), boxes(idx(j),:)); if iou > 0.5 keep(idx(j)) = false; end end end boxes = boxes(keep, :);computeIoU是自定义函数,按标准交集除以并集计算。这一步虽然代码简单,但直接影响检测框数量稳定性。
4. 从检测到追踪:用 KDE 密度场做数据关联与轨迹平滑
4.1 用密度图替代检测框做追踪:峰值跟踪 vs 聚类跟踪
检测出来的峰值可以直接当观测值进追踪器。传统“检测框 + IoU 匹配”的追踪,在检测框抖动时 IoU 波动很大;而密度峰值来自高斯滤波后的局部极大值,天然有亚网格稳定性。每一帧的峰值坐标就是这个人当前最可能的位置,追踪问题变成“如何把前后两帧的峰配对”。
峰值数量少的时候,直接做最近邻匹配。峰值数量多、密度高时,最近邻容易串 ID。我更推荐把每个峰的位置当作卡尔曼滤波的观测值,用状态预测做门控。卡尔曼滤波的好处是它显式建模了速度和位置,在短时遮挡时能继续预测位置,不会立刻丢轨迹。
4.2 数据关联:基于最近邻峰值匹配的 MATLAB 实现
追踪器的核心是一个结构体数组,每条轨迹包含卡尔曼状态、观测协方差和未匹配计数。初始化轨迹时,用峰值位置[cx, cy]作为初始状态。
% 创建一条轨迹 track.id = nextId; track.state = [cx; cy; 0; 0]; % x, y, vx, vy track.A = [1 0 1 0; 0 1 0 1; 0 0 1 0; 0 0 0 1]; track.H = [1 0 0 0; 0 1 0 0]; track.Q = eye(4) * 0.01; % 过程噪声:速度变化的容忍度 track.R = eye(2) * 1.0; % 观测噪声:峰值定位的精度 track.P = eye(4) * 10; track.age = 1;Q越大,卡尔曼越相信观测,轨迹预测越灵活,但也更容易被噪声带偏。R越小,越相信观测峰值位置。KDE 密度峰值通常比较稳定,R=1是合理起点。
逐帧预测和匹配的伪代码流程:
% 预测所有已有轨迹的状态 for tIdx = 1:numel(tracks) tr = tracks(tIdx); tr.state = tr.A * tr.state; tr.P = tr.A * tr.P * tr.A' + tr.Q; end % 当前帧峰值坐标 curPeaks: Nx2 predPos = cell2mat(cellfun(@(tr) tr.state(1:2)', tracks, 'UniformOutput', false)'); D = pdist2(predPos, curPeaks); % 轨迹预测位置到峰值的距离矩阵 % 门控最大距离:超过该距离的特征不考虑匹配 maxDist = 30; [assignments, unassignedTracks, unassignedDets] = greedyAssign(D, maxDist);greedyAssign把距离矩阵从小到大排序,逐对配对。它的优点是没有匈牙利算法那么严格,但胜在代码直观,匹配结果也能接受。maxDist=30是按网格坐标设置的,如果场景中行人移动快,可以放宽到 50;如果移动慢,收紧到 20,避免不同行人抢同一条轨迹。
4.3 处理遮挡与短暂丢失:轨迹预测与密度残差补偿
行人遮挡是追踪最大的坑。两个人交错时,KDE 峰值会先合并成一个,再分裂成两个。合并那几帧里,轨迹找不到自己的观测峰值。我的处理方式是:未匹配轨迹先不删除,用卡尔曼预测位置继续参与下一轮匹配,同时给轨迹的age加一。只有连续 6 帧以上没有匹配,才删除这条轨迹。这是最直接也最有效的短时遮挡兜底。
再深一层,可以在密度图上做峰分离。两个峰合并时,峰值位置偏到两人中间,直接拿这个峰做观测会把轨迹拉偏。此时用分水岭对密度图做粘连峰切割:
% 把密度图取反,分水岭找谷底,切分开合并峰 D = -densityGrid; L = watershed(D); densitySplit = densityGrid; densitySplit(L == 0) = 0; % 把分水岭边界处的密度值置零,形成两个独立峰分水岭容易过度分割,尤其是在密度图噪声较多时。所以我只在检测峰数量明显小于历史平均数量时启用这步。用imimposemin可以强制指定局部最小值,让分水岭只在两个峰之间切一刀,效果更可控。
5. 避坑:KDE 行人检测的 5 个翻车现场与排查清单
5.1 密度图糊成一片,检测框乱跳
现象:KDE 密度图上所有行人融为一体,峰值点不在人身上,检测框位置在几帧内大幅跳动。
原因:带宽太大,或者网格降采样太粗。高斯核的覆盖范围大于行人间距时,每个峰都被平滑到彼此连成一片,局部极大值失去意义。
解决:把sigmaGrid调小。做法是先观察一帧密度图,测量峰间距在网格上有多少个格子,让sigmaGrid不超过峰间距的一半。比如网格 120×80,两个人峰间距约 8 个网格,那sigmaGrid取 2~3 比较安全。调整后跑一段 20 帧视频,看峰值数量是否和目视行人数量接近。如果峰值仍然偏少,继续减小。
5.2 静止或缓慢移动的行人永远检不到
现象:行人站着打电话或排队不动,背景差分把 ta 归入背景,密度图上完全没有峰。
原因:这个方案的检测入口是运动前景,KDE 本身只能聚拢前景点,不能凭空创造静止目标。背景差分模型会逐步把静止时间过长的行人“学习”成背景。
解决:在背景差分之外并联一个静态目标检测通道。固定摄像头下,可以用长时间前景累积:维护一张浮点型“静止置信图”,每次fgMask为真的位置加 1,为假的位置衰减。当置信度超过阈值时,也把这些像素纳入前景点送进 KDE。这样站着的人即使背景差分丢了,置信图还能保留痕迹。要注意定期重置置信图,否则场景里搬进来的新物体也会被当成行人。
5.3 两个行人擦肩而过时轨迹发生 ID 互换
现象:追踪画面里两个人的标注 ID 在擦肩后对调了,视觉上看起来像是“穿模”。
原因:擦肩瞬间两个峰短暂合并,最近邻匹配把轨迹 A 匹配到了原本属于 B 的峰,再把 B 匹配到 A 的峰,导致 ID 交换。
解决:引入“外观特征”辅助匹配。在峰值附近的原图局部窗口提取灰度直方图或 HOG 特征,计算特征距离与空间距离的加权和。空间距离权重 0.7,直方图距离权重 0.3,能有效减少 ID Swap。如果不方便加特征,至少要给卡尔曼预测状态加门控:只有新峰值连续两帧落在某轨迹的预测门控内,才允许重新分配 ID。
5.4 直接调用 ksdensity 处理整帧导致 MATLAB 卡死
现象:把上一个模块得到的前景坐标直接塞给ksdensity,MATLAB 长时间无响应或内存暴涨。
原因:ksdensity默认会在每个评估点对每个样本计算核函数,前景点几千上万个,评估点再多几倍,复杂度直接爆炸。这个问题我在刚开始实现时也踩过,以为是 MATLAB 慢,其实是算法复杂度不对。
解决:不要用ksdensity处理图像级数据。第三章的网格 +imgaussfilt方案是更合适的工程简化。网格尺寸控制在 120×80 左右,高斯滤波由 MATLAB 原生加速完成,一帧处理时间能从几十秒降到几十毫秒级别。只有在离线分析小规模坐标点分布时才用ksdensity。
5.5 人群密集时三个人被统计成两个
现象:三人并排走时峰值数变成两个,中间人的位置被掩没。
原因:固定带宽无法适配局部密度差异。行人间距小于核宽时,多个峰融合成一个大峰,峰值数量偏少。
解决:对密度图做多尺度检测。第一遍用大带宽找出人群簇区域,第二遍在这些区域内用小带宽重新估计密度并找峰。另一种做法是用分水岭切开融合峰,但可能出现过分割,需要配合追踪器的时间一致性过滤。实际项目中,密集场景我一般把sigmaGrid降到 1.5,并接受偶尔的过分割,让追踪器在时间维度上做平滑。没有一劳永逸的参数,密集程度不同需要重新调。
6. 进阶:用 MOT 指标验证检测追踪精度,和参数自整定技巧
6.1 用中心点距离评估多目标追踪精度
KDE 检测追踪做完了,怎么证明它靠谱?建议用简化版 MOTA、MOTP。先手动标一小段视频的帧中心点,再与 KDE 峰值位置配对,计算平均中心点距离。
% truthPts: Mx2 手动标注中心; estPts: Nx2 KDE峰值 D = pdist2(estPts, truthPts); [minDist, matchIdx] = min(D, [], 1); meanError = mean(minDist); % 平均中心点像素误差meanError小于行人宽度的 30%,说明检测定位是可用的。再统计追踪 ID 切换次数,能直接暴露前面提到的 ID Swap 问题。
6.2 用热力图离线调带宽
我不会直接在监控现场碰运气调参数,而是录一段 2 分钟视频,每 30 帧暂停一次,记下真实行人数目,再跑 KDE 峰值统计。画一条“带宽 vs 峰值偏差”曲线,选择偏差最小的带宽值。这个离线标定过程只需一次,之后放到相似场景都能稳定复用。记住:KDE 的带宽不是越大越好,也不是越小越好,它只是在你的场景里让峰数和真实人数最接近的那个值。
我一般会在交付前专门录一段包含静止行人、双人交会和密集小组的测试视频,把前五节里的坑各复现一遍再上现场。这个方法胜在原理透明、参数可控,出了问题能一层层查下去,不会像黑匣子模型那样只能干瞪眼。希望帮到你。
本文还有配套的精品资源,点击获取