前阵子我帮一家园区做监控视频的离线分析,需求不复杂:统计每条通道一天的行人流量,最好还能画出每个目标的行走轨迹。人工盯录像实在不现实,一小时快进看完基本就是碰运气。后来我用MATLAB搭了一套视频行人检测与跟踪系统,把视频读取、运动目标提取、行人验证、目标跟踪、轨迹输出这整条流程打通。这篇文章就是那次实践的技术复盘,把方案选型、关键原理、可复现代码和踩过的坑一起写出来,适合正在做相关课程设计、毕业课题,或者想在低算力环境下快速验证视觉算法的朋友参考。
1. 整体设计思路:为什么用MATLAB、为什么选“先检测后跟踪”
1.1 方案选型背后的三条主线
很多人一听计算机视觉第一反应就是Python加OpenCV,但我在这个项目里坚持用MATLAB,不是因为它比Python强,而是场景太合适了。
第一,MATLAB的Computer Vision Toolbox里直接封装了前景检测器、HOG特征提取、卡尔曼滤波、匈牙利匹配这些底层模块,调用起来非常省事。传统视频分析的核心难点不在于写算法本身,而在于把检测、验证、关联、生命周期管理这条链路的逻辑理清楚。用MATLAB能把更多时间花在流程设计和参数调优上,而不是造轮子。
第二,项目的运算环境是无GPU的普通台式机。跑深度学习行人检测器(YOLO、Faster R-CNN这一类)在这台机器上会比较吃力,但传统方法HOG加SVM在CPU上表现尚可。我实测过,1080P视频分辨率降到640x360后,单帧处理时间大约在80到120毫秒之间,基本能跑到8到12帧每秒,对于离线分析完全够用。
第三,MATLAB的调试体验对视觉程序很友好。你可以随时停在一个断点上,把当前帧、前景掩码、检测框、预测框全部可视化出来,逐步看清楚问题出在哪一步。这一点在项目初期排查检测漏报和跟踪跳变时帮了大忙。
1.2 整体流程:从视频帧到追踪轨迹
整个系统的处理链路可以概括为“先分割、再验证、后关联”三个阶段。第一阶段用背景建模把画面中运动的前景区域抠出来;第二阶段在候选区域上做行人验证,剔除那些“会动但不是人”的目标;第三阶段把连续帧的检测框关联成轨迹,用滤波平滑运动路径。
这样设计有一个核心考量:不直接在全图上滑动窗口检测行人。全图扫描在CPU上的计算成本太高,而且会产生大量误报。先把运动区域筛出来,检测器只需要处理少数几个候选框,速度有几个数量级的提升,准确率也更可控。
整个系统的工作流程分为以下步骤:
- 读取视频流,转灰度图并做尺寸缩放。
- 用高斯混合模型(GMM)做背景建模,得到前景掩码。
- 对前景掩码做形态学开闭运算,填补空洞、去除噪点。
- 用连通域分析提取候选框,并按面积和宽高比过滤。
- 在候选框上用HOG特征加SVM分类器确认是否为行人。
- 卡尔曼滤波器预测已有目标的位置,将检测框与预测框做匹配。
- 管理跟踪对象的新建、确认、删除状态。
- 绘制检测框和运动轨迹,必要时输出结果视频。
这套结构每一步都是独立的模块,替换检测算法或跟踪策略都相对方便。下面我按处理顺序,把每个环节的原理和参数选择展开讲。
2. 运动目标提取:让检测器只关注“会动的东西”
2.1 为什么不能直接全图跑HOG
我见过很多初学者直接拿HOG检测器对每一帧全景做多尺度滑动窗口扫描,结果程序慢到无法接受。一张1080P的画面按64x128的窗口滑动,再叠加多个尺度,产生的候选窗口数以万计,每个窗口都要提取特征、过分类器,在CPU上单帧可能要好几秒。
背景建模的价值就在这里。监控摄像头通常是固定的,画面中除了运动目标,背景基本不变。利用这个特性建立一个背景模型,把当前帧和背景模型做差分,就能得到前景区域。行人验证只需要在这些前景区域上做,候选窗口数量从几万个降到几十个甚至更少,检测效率和准确率都是数量级上的提升。
需要说明的是,这套思路只适合摄像头静止的场景。如果摄像头本身在运动,比如云台摄像机或者车载摄像头,背景差分会完全失效,需要换用光流或者更复杂的运动补偿方案。这也是项目立项时就要先确认的约束条件。
2.2 GMM背景建模的原理与参数选择
背景差分的关键是背景模型的建立和更新。最简单的做法是取前几十帧的平均值作为背景,但实际场景中光照会渐变、树枝会晃动、水面会反射,固定背景模型很快失效。我采用的方案是基于高斯混合模型的背景减除,MATLAB里直接调用vision.ForegroundDetector即可。
它的核心思想是:对每个像素点维护多个高斯分布,分别描述不同状态下的颜色概率分布。经常出现的分布对应背景,偶尔出现的分布对应前景。每一帧到来时,用像素值和现有分布逐一匹配,命中的分布就更新它的均值和方差;没有命中的分布,如果超过最大数量,就会用新的分布替换权重最低的那个。
参数的选择需要根据场景特点调。关键参数有四个:
NumGaussians:每个像素最多维护的高斯分布数量,默认5。对监控场景,3到5都可以,分布太少会难以适应树叶晃动等多模态背景。NumTrainingFrames:训练帧数,默认50。前N帧用于初始化背景模型,如果现场有频繁走动的人群,可以适当提高到100,避免把行人误学进背景。MinimumBackgroundRatio:作为背景判断的权重阈值,默认0.7。它表示一个分布至少要占模型总权重的多少比例才被当作背景。阈值调小,背景更“顽固”,前景更敏感;调大则相反。AdaptiveRate:模型更新速度。这个参数MATLAB在较新版本里用LearningRate控制,值越大背景更新越快。我通常设为0.005到0.01之间,太快会把长时间逗留的行人“吸收”进背景,太慢则无法适应光照变化。
其中有个比较有意思的问题是阈值对检测的影响。MinimumBackgroundRatio如果设得过高,背景模型会更敏感地进行前景判断,结果树影晃动、光线变化都会被当成运动目标,产生大量候选框;设得过低又会漏掉浅色衣服的行人。我在项目里采用的办法是,先用默认值跑一遍,把前景掩码可视化出来观察,再根据实际效果微调。这种做法比直接套理论参数要直观得多。
2.3 形态学后处理:把散点拼成完整目标
GMM输出的前景掩码是一张二值图,但看起来通常很不干净。行人衣服纹理、颜色与背景相近的部分会被漏成空洞;地面反光、树叶抖动又会生成孤立噪点。直接拿这个掩码提取连通域,结果会很糟糕。
形态学操作在这里起到“清理”的作用。我用到的操作有两类。
首先是开运算,即先腐蚀再膨胀,用来去除孤立的小噪点。核大小我通常用strel('disk', 3),这个参数对640x360的画面来说比较合适。核太小去不掉大一点的噪点团,核太大会把行人的肢体细节也抹掉。
然后是闭运算,即先膨胀再腐蚀,用来填补行人内部的小空洞,把断裂的身体区域连通起来。仍然是圆形结构元素,半径可以取5。如果目标离摄像头较远,在画面里很小,半径可以降到3,避免过度膨胀让两个相邻的行人粘连在一起。
值得提醒的是,形态学后处理只能解决“小尺度”的图像缺陷,不能解决“大尺度”的遮挡和重叠问题。行人A和行人B在画面上交叠时,闭运算后两个目标的掩码很可能连成一片,变成一个极大的连通域。这种场景单靠形态学处理不够,我在第4.3节会专门讲应对方法。
3. 行人确认:HOG特征与SVM分类器的核心逻辑
3.1 HOG特征的提取逻辑与维度计算
运动检测筛出来的候选框里“会动的东西”很多,穿行的猫狗、飘动的旗帜、车辆的窗影都可能是运动前景。需要另一种特征来确认候选框里到底是不是人。这个特征就是方向梯度直方图HOG。
HOG特征的直觉理解是:图像中物体的形状,可以通过其局部区域的梯度方向和强度分布来描述。行人虽然穿衣颜色、体态各不相同,但轮廓上总有明显共性——头部圆、肩膀宽、腿是两条直立柱状区域。这些共性反映在梯度方向上,就有稳定的统计特征。即使前景掩码不干净,HOG特征仍然可以基于灰度和边缘信息工作,这是一个很实用的特性。
以MATLAB内置行人检测器的标准窗口64x128为例,HOG特征的计算过程大致为:把窗口划分成8x8像素的细胞单元,每个细胞单元统计9个方向区间上的梯度直方图;再把相邻2x2的细胞组合成一个块,并把四个细胞的特征拼接起来。块与块之间按步长1个细胞滑动,于是水平方向有(64/8-2+1)=7个位置,垂直方向有(128/8-2+1)=15个位置,块的总数是7*15=105;每块特征维度为36,整个窗口最终得到一个105*36=3780维的特征向量。这个向量随后送入分类器,判断窗口内容是否为行人。
在MATLAB中,extractHOGFeatures函数可以自动完成提取,函数会返回特征向量和可视化图像。这里有一个细节值得留意:函数默认的CellSize是8x8左右(不同版本参数略有差异),但对远距离小目标的检测,可以把CellSize调整为16x16以减少噪声影响。我在工程中会优先验证默认参数,只有在检测率明显不足时才调整。
3.2 训练样本准备与分类器训练流程
虽然MATLAB内置的vision.PeopleDetector提供了训练好的HOG行人模型,但它的模型是基于常见监控视角训练的,在俯视、顶视或特殊机位下,检测效果会明显下降。这在园区出入口、商场顶视相机等场景里非常常见。因此我建议训练一个自己场景的分类器,步骤不算复杂。
准备数据集时,要尽量贴合当前摄像头视角。正样本从自己的视频里截取行人区域,大小统一缩放为64x128,数量至少几百到上千张。负样本则随意截取背景区域、空地和车辆等非行人区域,数量控制在正样本两倍左右即可。我建议从自己的视频中收集负样本,因为负样本的分布和实际场景越贴近,分类器的针对性越强。
训练的核心代码如下:
% 读取正负样本路径 posImages = imageDatastore('positives/'); negImages = imageDatastore('negatives/'); % 提取HOG特征 posFeatures = []; for i = 1:numel(posImages.Files) img = imresize(imread(posImages.Files{i}), [128, 64]); features = extractHOGFeatures(img); posFeatures = [posFeatures; features]; end negFeatures = []; for i = 1:numel(negImages.Files) img = imresize(imread(negImages.Files{i}), [128, 64]); features = extractHOGFeatures(img); negFeatures = [negFeatures; features]; end % 训练线性SVM trainFeatures = [posFeatures; negFeatures]; trainLabels = [ones(size(posFeatures,1),1); zeros(size(negFeatures,1),1)]; svmModel = fitcsvm(trainFeatures, trainLabels, 'KernelFunction', 'linear', 'BoxConstraint', 0.1);BoxConstraint这个参数我一开始没怎么留意,实际测试发现它直接影响误检率。取值过大时模型对训练样本拟合得过于苛刻,泛化能力差;取值过小则分类边界过于松弛,负样本更容易被放进来。经验值在0.1到1之间比较稳妥。
3.3 误检抑制的实用技巧
模型训练完成后,推理阶段仍然会有误检测,尤其是候选框紧贴墙壁阴影、地面斑马线纹理时,分类器容易被梯度模式欺骗。为此我在验证模块里叠加了几个层面的约束。
第一层是几何约束。行人的宽高比大约在0.4左右,即宽度高度比约为2:5。设定宽高比在0.2到0.6之间、面积在[500, 10000]像素范围内的候选框才进入分类器验证。这个过滤可以把大多数孤立的小噪点和超大物体直接挡在门外。
第二层是分类器置信度阈值。vision.PeopleDetector的ClassificationThreshold参数默认0.5,但在实际场景中0.5往往太宽松。我测试时发现,阈值调到0.7后误检数量大幅下降,同时召回率只损失了几个百分点。对离线分析来说,宁可少报一些,也不能让视频输出框满天飞。
第三层是时间域一致性。单帧分类置信度波动较大,但真实目标在连续多帧中通常会反复出现。利用跟踪模块里的轨迹命中次数,如果一个检测框在连续多帧都能匹配到同一个轨迹,才视为有效目标。这个机制可以有效抑制那些在单帧偶尔被误检出来的“幽灵目标”。
4. 跟踪关联:卡尔曼滤波和匈牙利匹配的配合
4.1 卡尔曼滤波的状态设计与参数选择
检测器得到的目标框只是每帧的独立结果,如果直接把框连接起来,坐标抖动会很明显,还可能出现目标短暂被遮挡后无法判断是否同一人的问题。这时需要卡尔曼滤波来做运动估计和状态平滑。
卡尔曼滤波的思想是:把目标的运动建模为一个状态向量,每一帧先根据上一帧的状态预测当前位置,再用当前检测结果作为观测值对预测进行修正。预测和修正的权重由噪声协方差矩阵决定。我设计的跟踪状态是8维向量:
状态向量 x = [cx, cy, w, h, vx, vy, vw, vh]^T 观测向量 z = [cx, cy, w, h]^T中心坐标加宽高作为观测,速度分量作为隐状态。状态转移矩阵按匀速运动模型定义:
A = eye(8); A(1,5) = dt; A(2,6) = dt; A(3,7) = dt; A(4,8) = dt;这里的dt是两帧间隔时间,对于25帧每秒的视频,dt=0.04秒。实测下来匀速模型在监控场景下足够用了,行人短时间内运动近似匀速,不需要复杂的加速度模型。
过程噪声协方差Q和观测噪声协方差R的取值是整个滤波效果的关键。Q描述了我们对运动模型的信任程度,取太大预测结果会非常跳跃;R描述了检测框坐标的噪声水平,取太大则过于信任预测而忽略当前检测。我通常将Q设置为对角线为0.01的8x8矩阵,R设置为对角线为10的4x4矩阵,然后在可视化轨迹时观察平滑效果微调。如果轨迹出现明显的“折线感”,说明Q偏大;如果轨迹滞后于真实位置,说明R偏大。
4.2 匈牙利匹配与跟踪生命周期管理
有了卡尔曼预测后,要做的事情是把当前帧的检测框和已有轨迹关联起来。我使用匈牙利算法求解最优匹配,代价矩阵里每个元素是检测框和预测框的交并比IOU,没有重叠的目标代价设为无穷大,匹配时限制只能匹配IOU大于某个阈值的对。
MATLAB里可以自己写一个简单的IOU计算函数,然后调用匈牙利匹配的实现。我采用的方法是:
% 计算所有轨迹与检测框的IOU代价矩阵 costMatrix = 1 - iouMatrix; % 使用匈牙利算法(思路参考贪心匹配或官方assignment函数) matches = assignment(costMatrix, 0.3);关于assignment函数,MATLAB的优化工具箱中提供了这个接口,需要传入代价矩阵和未匹配代价阈值。阈值取0.3表示只有IOU大于0.3的检测框和轨迹才有资格进入匹配。这个值我在项目中实测过:阈值偏大会导致检测框抖动时匹配失败,轨迹碎片化;阈值偏小则容易把相邻两个不同行人的框错误关联。
跟踪生命周期管理我分为三种状态:未确认轨迹、确认轨迹、已删除轨迹。
新检测框出现时,先建立一个未确认轨迹,初始年龄是0。连续出现minHits=3帧后转正为确认轨迹。转正条件是为了过滤掉那些只出现了一两帧就消失的误检。已确认轨迹如果连续maxInvisible=8帧没有匹配到任何检测框,说明目标可能已经离开视野或永久性被遮挡,直接删除。旷课计数在每一帧后递增,一旦有新的匹配就清零。
4.3 遮挡和短暂消失场景的应对
行人跟踪最大的敌人是遮挡。在园区通道里,两个人对面路过错身而过的瞬间,检测框通常会短暂重叠甚至合并,导致IOU匹配直接把轨迹转入“不可见”状态。
一个比较实用的策略是用预测框“续命”。当目标没有匹配到任何检测框时,我们仍用卡尔曼预测的位置绘制轨迹和显示框,而不是立刻删除。如果目标在短暂遮挡后重新出现,并且检测框与预测框的IOU满足阈值,就能够重新关联上。这个策略在实际测试中把轨迹断裂比例降低了差不多三分之一。
针对多人粘连的情况,我的处理办法是:如果检测框面积明显大于单个人体范围,且和多个轨迹同时重叠,则优先把检测框匹配给与它IOU最大且运动方向更接近的那个轨迹;若无法确定,宁可让它继续保持不可见状态,也不要给多个轨迹同时喂同一个检测框,否则轨迹会互相“抢”目标。这个经验看似简单,但一开始照搬标准匹配逻辑时,我确实踩过轨迹互换的坑。
5. 代码落地:主程序框架与关键函数拆解
5.1 主程序结构
整个系统的代码组织为三个核心文件:
main_demo.m:主程序,负责视频读取、循环处理、结果播放和轨迹输出。detectCandidates.m:输入当前帧,返回一个包含了候选框坐标的矩阵,内部完成GMM前景检测、形态学处理、连通域过滤和HOG分类。trackObjects.m:输入上一帧的跟踪状态和当前检测框,输出更新后的跟踪状态列表和匹配结果。
这种结构能把检测和跟踪解耦开,在日常调试时很有帮助。如果你只想跑通流程,也可以用单文件把步骤写清楚。我建议还是保持模块化,因为后续换数据集、换摄像头角度时,往往只需要调整检测参数,跟踪部分完全不受影响。
主程序的循环结构大概如下:
videoReader = vision.VideoFileReader('input_video.mp4'); videoPlayer = vision.VideoPlayer('Position', [100 100 680 480]); fgDetector = vision.ForegroundDetector('NumGaussians', 3, ... 'NumTrainingFrames', 50, 'MinimumBackgroundRatio', 0.7); peopleDetector = vision.PeopleDetector('acf', 'ClassificationThreshold', 0.7); tracks = initializeTracks(); % 空结构数组 frameIdx = 0; while ~isDone(videoReader) frame = step(videoReader); [candidateBoxes, foregroundMask] = detectCandidates(frame, fgDetector, peopleDetector); tracks = trackObjects(tracks, candidateBoxes, frame); displayResults(frame, tracks, foregroundMask); frameIdx = frameIdx + 1; end release(videoReader); release(videoPlayer);注意vision.ForegroundDetector有内部状态,必须在整个过程中只创建一次,不能每一帧都重置。如果放在循环内部调用,背景模型就永远学不出来。这也是新手容易犯的错误。
5.2 检测与跟踪模块的关键实现
检测模块里,前景掩码后的连通域分析是核心。我用regionprops提取候选区域后,按面积、宽高比过滤,再准备送入HOG检测器。为了适配vision.PeopleDetector的输入尺寸要求,候选区域需要先裁剪出来,缩放为检测器要求的尺寸再调用。
function [candBoxes, mask] = detectCandidates(frame, fgDetector, peopleDetector) I = im2gray(frame); mask = step(fgDetector, I); % 形态学后处理 se1 = strel('disk', 3); se2 = strel('disk', 5); mask = imclose(imopen(mask, se1), se2); % 提取连通域 stats = regionprops(mask, 'BoundingBox', 'Area'); candBoxes = zeros(0, 4); for i = 1:numel(stats) bb = stats(i).BoundingBox; area = stats(i).Area; ratio = bb(3) / bb(4); if area > 400 && ratio > 0.2 && ratio < 0.8 candBoxes = [candBoxes; bb]; end end % 对候选框执行行人检测 if ~isempty(candBoxes) candBoxes = step(peopleDetector, frame, 'ROI', candBoxes); end end跟踪模块的实现核心是卡尔曼滤波的更新和匈牙利匹配。这里给出简洁的状态结构定义:
function tracks = initializeTracks() tracks = struct('id', {}, 'kalmanFilter', {}, ... 'bbox', {}, 'age', {}, 'totalVisibleCount', {}, ... 'consecutiveInvisibleCount', {}); end每一轮更新时,先用所有轨迹的卡尔曼滤波器预测当前帧的位置。预测后的bbox和当前帧检测框中,将IOU超过阈值的构建代价矩阵,调用匹配函数得到匹配结果,再对匹配上的轨迹执行correct修正,对未匹配的新检测框建立新轨迹,对未匹配的旧轨迹递增不可见计数。
5.3 性能评估与参数复盘
完工后我对系统做了定量评估。测试视频来自园区出入口的固定摄像头,分辨率1920x1080,帧率25fps。我把输入缩放为640x360,GMM训练帧数50,HOG阈值0.7。总共测试了约1200帧,用人工标注结果对比:
- 精确率约91%,误检主要发生在光线突变和行人靠近摄像头导致目标过大时。
- 召回率约86%,漏检大多是因为行人穿着与背景颜色相近,前景分割失败,目标区域没有进入候选框。
- 平均单帧处理耗时约95毫秒,离线分析1小时视频大约需要28分钟。
这个表现不算顶尖,但在CPU环境下,传统方法能达到这样一个水平,已经具备工程使用价值。对于规律性较强的固定摄像头监控场景,这类方案在成本和部署速度上的优势非常明显。
6. 踩坑记录与常见问题排查
6.1 常见问题速查表
项目过程中我记录了不少问题,这里整理成速查表,按照“表现、原因、解决办法”的方式列出:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 前景掩码全是噪点 | GMM训练帧数过少 | 调高NumTrainingFrames到80-100 |
| 行人内部大量空洞 | 闭运算核过大或过小 | 检查形态学核大小,试3-7范围 |
| 检测框乱跳 | 分类阈值过低 | 提高ClassificationThreshold到0.7 |
| 目标轨迹断裂 | 匹配IOU阈值过高 | 降低匹配阈值至0.3,允许预测框续命 |
| 多个轨迹互换目标 | 检测框粘连 | 限制一个检测框只能匹配一个轨迹 |
| 长时间停留的行人消失 | 背景学习速度过快 | 降低LearningRate至0.005左右 |
| 程序内存持续增长 | 跟踪结构无限增长 | 增加轨迹删除逻辑,及时清理不可见旧轨迹 |
| 车辆被识别为行人 | 车辆轮廓与行人相似 | 叠加运动方向、速度约束过滤大面积目标 |
这些问题的共同特点是,参数之间往往是联动的。调一个参数发现问题好转,另一个问题又出来了,最好每次只动一个变量,并用固定片段做回归测试。
6.2 几个容易忽略的细节
有一个细节是关于vision.PeopleDetector的检测窗口尺寸。不同版本内置模型的输入窗口差异会导致同一段视频检测效果完全不同。比如默认模型的设计分辨率是64x128,当你把缩得很小的视频帧直接送入时,远处行人可能只有十几个像素高,特征根本提不出来。建议对远距离行人采用两级缩放策略,先检测大目标,再对放大后的区域做二次检测。
还有一个容易被忽略的问题是视频编码格式。MATLAB的VideoFileReader对H.264编码的支持在不同版本间略有差异,有些机器上会读不出视频或者出现帧错位。如果遇到这种问题,先用格式工具把视频转成Motion JPEG或未压缩AVI,再送入程序,能省去不少排查时间。
最后是结果的输出。离线分析项目往往需要输出带框视频和CSV轨迹文件,而不仅仅是屏幕显示。使用vision.VideoFileWriter写结果视频时,需要注意输出分辨率要与显示窗口一致,否则写出来的视频会被插件强制拉伸。CSV输出则需要把跟踪结构的id、bbox、时间戳一次性记录,方便后续做流量统计和热力图分析。
在实际项目中我还发现一个小技巧:把GMM的前景掩码叠加到结果视频的角落,能让调试人员快速定位问题是在检测阶段还是跟踪阶段。如果掩码干净但检测框丢失,问题基本在分类器;如果掩码就有问题,那么后处理再合理也无济于事。这个“分层排查”的习惯,帮我节省了大量时间。