前阵子我帮一家园区做监控视频的离线分析需求不复杂统计每条通道一天的行人流量最好还能画出每个目标的行走轨迹。人工盯录像实在不现实一小时快进看完基本就是碰运气。后来我用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-21)7个位置垂直方向有(128/8-21)15个位置块的总数是7*15105每块特征维度为36整个窗口最终得到一个105*363780维的特征向量。这个向量随后送入分类器判断窗口内容是否为行人。在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帧每秒的视频dt0.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。连续出现minHits3帧后转正为确认轨迹。转正条件是为了过滤掉那些只出现了一两帧就消失的误检。已确认轨迹如果连续maxInvisible8帧没有匹配到任何检测框说明目标可能已经离开视野或永久性被遮挡直接删除。旷课计数在每一帧后递增一旦有新的匹配就清零。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。我把输入缩放为640x360GMM训练帧数50HOG阈值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的前景掩码叠加到结果视频的角落能让调试人员快速定位问题是在检测阶段还是跟踪阶段。如果掩码干净但检测框丢失问题基本在分类器如果掩码就有问题那么后处理再合理也无济于事。这个“分层排查”的习惯帮我节省了大量时间。