简介:一套基于MATLAB的车辆检测系统完整工程,面向交通监控、车流统计和车速测量等应用场景,适合图像处理及MATLAB GUI开发方向的学生和工程师使用。工程将视频采集、运动目标检测、瞬时速度与平均速度计算、车流量统计集成在可视化界面中,并配有详细代码注释,便于读者理解从图像预处理到结果输出的完整链路。项目代码覆盖背景差分、帧间运动估计、目标跟踪和计数等常见实现思路,再配合GUI交互控件进行参数调整与结果展示,实用性较强。压缩包共8个文件,包含4个M程序文件、1个GUI界面文件、1个AVI视频样例、1个数据文件及1个导入说明文档,整体仅483KB,轻量精简,解压后即可在MATLAB环境中运行体验。目前已有55人浏览学习,适合希望掌握车辆检测算法与GUI交互设计的开发者进行练习和二次开发。
1. MATLAB车辆检测系统,为什么传统视觉方案反而更实用
很多团队一提车辆检测就直接上YOLO,但在固定机位的交通视频场景下,MATLAB 自带的 Computer Vision Toolbox 用背景减除加 Blob 分析,不需要训练样本就能识别车辆,还能直接在同一个循环里完成车流量计数、速度计算和 GUI 展示。这个项目真正难的地方不是“检测出车”,而是怎么避免同一辆车被重复计入车流量,以及如何把像素位移换算成真实车速。它适合课程设计、毕业设计和快速原型验证,也适合已有深度学习方案但想对比传统视觉成本的工程师。标题里的“详细注释”意味着代码不只是跑通,还要让组员、答辩老师或同事能一行行读懂链路:从 foreground mask 到 bounding box,再到跨线计数和速度输出。
2. 用背景减除做MATLAB车辆检测:先选算法,再调参数
2.1 固定摄像头场景下,为什么优先选背景减除而不是帧差法或深度学习
车辆检测的常见算法有三类:帧差法、背景减除、深度学习目标检测。帧差法用相邻两帧相减,代码最简单,但车辆静止或速度很慢时,前后两帧几乎一致,foreground mask 会消失,造成漏检。深度学习检测精度高,但 MATLAB 里要额外装 Deep Learning Toolbox,还要准备数据集做训练或导入大网络,在只有普通 CPU 的机器上往往跑不到实时帧率。背景减除正好卡在两者之间:它用前几十帧建立背景模型,后续每一帧都与背景模型比较,和背景差异大的像素就标成前景。固定摄像头场景下,背景像素在短时间内相对固定,因此路灯、树木、路面的变化都能被模型慢慢吸收,长时间运行时依然能把车辆挖出来。
这里还有一个工程考虑:后续要算速度和车流量,本质上是追踪车辆质心位置,而不是识别车辆品牌或颜色,所以只需要“前景区域”而不是“目标分类”。背景减除输出的 foreground mask 可以直接输入给 Blob 分析,几行代码就能拿到每个车辆区域的面积、质心和外接矩形。这样的数据链足以支撑后面的虚拟检测线和帧号差计算,比深度学习方案更轻,也更容易在答辩时解释清楚。
2.2 用 vision.ForegroundDetector 生成前景掩码
MATLAB 的 Computer Vision Toolbox 提供了基于混合高斯模型的vision.ForegroundDetector,对应 OpenCV 里的 MOG2。它的原理是对每个像素保留若干个高斯分布,像素值落在已有分布内就认为是背景,否则认为是前景。摄像头不动时,这条路线的宽容度很高。最小可运行代码段如下:
% 读取视频文件,traffic_clip.mp4 用你自己的文件名替换 videoReader = VideoReader('traffic_clip.mp4'); % 建立前景检测器 foregroundDetector = vision.ForegroundDetector( ... 'NumGaussians', 3, ... % 每个像素的混合高斯模型个数 'NumTrainingFrames', 50, ... % 前50帧用于背景建模 'LearningRate', 0.005); % 背景更新速度,数值越大对光照变化越敏感 frameCount = 0; while hasFrame(videoReader) frame = readFrame(videoReader); frameCount = frameCount + 1; % 调用检测器并更新背景模型 foregroundMask = foregroundDetector(frame); % 训练阶段的掩码不要直接用于后续检测 if frameCount <= 50 continue; end % 从第51帧开始,foregroundMask 才代表真正的运动目标 end这段代码里有三个参数需要重点解释。NumGaussians设为 3 足够描述路面、阴影和车辆三态;设得更大,背景模型更复杂,但内存占用和计算量也会上涨。NumTrainingFrames是背景建模帧数,摄像头架好后场景越空越静止,可以设为 30;如果车流密集,需要 80 到 100 帧才能学到干净的路面背景。LearningRate控制背景更新速度,0.005 意味着大约 200 帧才会显著更新一次背景模型,这样可以避免慢速车辆被当成背景吸收,但遇到树影晃动会比较敏感。实际调试时可以把LearningRate从 0.001 到 0.02 扫一遍,观察车辆经过时掩码是否连续。
版本上还容易踩一个坑:MATLAB R2016a 之前写step(foregroundDetector, frame),而较新版本直接写成foregroundDetector(frame)。两种写法在对旧项目做维护时都会遇到,建议在新代码里用函数式调用,代码更干净。
2.3 形态学处理与 Blob 筛选的 3 个参数
直接从背景减除得到的前景掩码会带很多孤立噪点,车辆内部也可能出现空洞。常见做法是先做一次闭运算填充空洞,再做一次开运算去除小噪点,最后用bwareaopen删除面积过小的连通域。
% 闭运算:填充车辆内部空洞,核选矩形更贴合车轮廓 maskClosed = imclose(foregroundMask, strel('rectangle', [5, 5])); % 开运算:去除路面上的孤立噪声点 maskOpened = imopen(maskClosed, strel('rectangle', [3, 3])); % 删除面积小于200像素的前景块 maskClean = bwareaopen(maskOpened, 200); % 用Blob分析提取车辆的外接框和质心 blobAnalyzer = vision.BlobAnalysis( ... 'BoundingBoxOutputPort', true, ... 'AreaOutputPort', true, ... 'CentroidOutputPort', true, ... 'MinimumBlobArea', 200); [areas, centroids, bboxes] = blobAnalyzer(maskClean);strel('rectangle', [5, 5])里的 5 表示闭运算核宽度,核太大会把紧挨着的两辆车融合成一个 blob,核太小背景噪声又消不掉。MinimumBlobArea是最关键的一个参数,它和视频分辨率强相关。720P 画面里,一辆小汽车在近处的投影面积可以达到几千像素,在远处可能只有 100 多像素,所以阈值不能只看一个值,要按视频里最远处的车辆面积来定。下面是一组常用参考值:
| 视频分辨率 | 最小车辆面积 | 形态学核大小 | 适用场景 |
|---|---|---|---|
| 640x480 | 100 | [3, 3] | 近距离路口 |
| 1280x720 | 200 | [5, 5] | 常规路侧机位 |
| 1920x1080 | 400 | [7, 7] | 俯拍且画面较大 |
面积阈值不是越低越好。阈值低了,行人、电动车、树叶影子都会被当成车辆;阈值高了,远处的小车在进入检测区域的前几帧会被丢掉,直接影响后面的计数和速度测量。
3. 车流量统计的实现:虚拟检测线与状态机
3.1 虚拟检测线的坐标设定
车流量统计最常用的方案不是在整幅图像上做追踪网络,而是在画面里画一条或多条虚拟检测线。车辆质心从检测线的一侧移动到另一侧,就认为完成一次通过。这个方案和物理计数器的原理一样,代码量小,也方便在 GUI 上把检测线画出来给用户看。
检测线位置需要避开三块区域:画面边缘、车辆经常停放的位置、树影和斑马线。一般选在道路长度的中部靠下一点,这样车辆已经完整进入画面,外接框大小也相对稳定。假设视频画面大小为 720x1280,道路从画面上方向下行驶,检测线可以先放在纵坐标 420 附近。
detectLineY = 420; % 虚拟检测线的纵坐标,按实际机位调整这里只记录一条线。如果需要区分双向车流,需要再放一条线,并区分车辆质心是从上往下还是从下往上穿越。单线无法判断方向,但在大多单方向车流的课程设计中已经够用。
3.2 防止同一辆车被重复计数的状态机
计数最怕的不是漏检,而是同一辆车反复横跳检测线。车辆质心靠近检测线时会随着外接框抖动而左右漂移,可能一帧在线上一帧在线下,如果只要“质心在线下就计数”,一辆车可能会被记三次。正确做法是为每个轨迹维护当前在检测线哪一侧的状态,只有检测到“从上一侧跨到另一侧”时才计数。
% 轨迹数组,每个元素保存质心坐标、所在侧、是否已计数 tracks = struct('x', {}, 'y', {}, 'side', {}, 'counted', {}); % 匹配阈值,单位像素,可看成同一辆车两帧之间的最大移动距离 matchingThreshold = 25; % 对每一帧提取到centroids后执行 for i = 1:size(centroids, 1) x = centroids(i, 1); y = centroids(i, 2); % 找最近的已有轨迹 if isempty(tracks) nearestIdx = -1; else dist2 = (tracks.x - x).^2 + (tracks.y - y).^2; [minDist, candidateIdx] = min(dist2); if sqrt(minDist) <= matchingThreshold nearestIdx = candidateIdx; else nearestIdx = -1; end end if nearestIdx == -1 % 新轨迹:记录当前所在侧,side = +1表示检测线下方 newTrack.x = x; newTrack.y = y; newTrack.side = sign(y - detectLineY); newTrack.counted = false; tracks(end+1) = newTrack; %#ok<AGROW> else % 更新已有轨迹位置 tracks(nearestIdx).x = x; tracks(nearestIdx).y = y; oldSide = tracks(nearestIdx).side; newSide = sign(y - detectLineY); % 状态变化且尚未计数,才计一次 if ~tracks(nearestIdx).counted && oldSide == -1 && newSide == 1 vehicleCount = vehicleCount + 1; tracks(nearestIdx).counted = true; end tracks(nearestIdx).side = newSide; end end这段代码的核心是sign(y - detectLineY)。newSide大于 0 表示质心在检测线下方,小于 0 表示在上方。轨迹第一次建立时只记录侧别,不计数;之后每一帧都更新侧别,只有检测到oldSide == -1变成newSide == 1这个边沿时才计数。这样质心在检测线附近来回抖动不会触发计数,因为只有跨越边界的那一次会被记为有效事件。
matchingThreshold需要按帧率和车速调整。25fps 视频里一辆 40km/h 的车每帧移动约 0.44 米,如果标定结果是每像素 0.02 米,那就是 22 像素,所以阈值取 25 合理。阈值太大会把相邻两车合成一条轨迹,太小会让同一辆车被拆成多个 ID,计数反而偏高。要真正校准这个值,必须先做下一章的速度标定,再把像素位移换算出来。
3.3 用 MATLAB 把车流量输出成时段统计
计数结果除了显示总数,还应该按时间段输出,比如每分钟一辆车。简单做法是把每辆车被计数的帧号存进数组,处理完视频后统一换算成秒。
countedFrames = double(crossFrames); % 假设crossFrames里存了每次计数时的帧号 fps = videoReader.FrameRate; timeInSeconds = countedFrames / fps; % 按60秒窗口统计车流量 windowSize = 60; edges = 0:windowSize:max(timeInSeconds); countPerWindow = histcounts(timeInSeconds, edges); % 输出表格 flowTable = table(edges(1:end-1)', countPerWindow', ... 'VariableNames', {'TimeFromStartSecond', 'VehicleCount'}); disp(flowTable);这里把帧号转成相对视频开始的时间戳,再用histcounts按秒级分箱统计。注意crossFrames必须和上一节vehicleCount同时更新,计数发生时就把当前frameCount推进数组,否则后面统计无据可查。若视频长度超过几小时,建议每隔 60 秒自动打印一次累计流量,方便在 GUI 上做成动态表格。
4. 速度与平均速度计算:标定、计时与两种平均速度
4.1 像素距离与物理距离的标定方法
速度计算绕不开一个问题:图像里车辆移动 100 像素,物理上到底走了多少米。不做标定只能输出“像素/秒”,没法直接给 km/h。交通视频里最可靠的标定物是车道分道线,国标常见白色虚线长 6 米,两线间隔 9 米,也就是一组虚线从起点到下一个起点相距 15 米。在画面里手动点出两个已知物理距离的点,就能算出每个像素对应多少米。
% 在图像上取两个已知距离的像素坐标 p1 = [210, 380]; % 画面上的点1 p2 = [280, 420]; % 画面上的点2,两点之间物理距离已知 % 已知两点间实际距离,单位米 realDistanceMeters = 10; % 换算比例:每像素多少米 scaleMeterPerPixel = realDistanceMeters / norm(p2 - p1);这个方法假设成像平面与道路平面平行,实际监控大多是俯视或斜视,画面近处和远处的尺度不完全一样。更精确的标定需要做透视变换或至少分段标定:把道路分成远近两段,分别计算远处的每像素米数和近处的每像素米数,然后再换算。
4.2 用两条检测线计算瞬时速度
有了标定比例,速度就可以用帧号差算。放两条检测线,分别记为lineInY和lineOutY,当车辆质心跨过第一条线时记录当前帧号,跨过第二条线时再记录当前帧号,两线之间的实际距离已知,帧号差除以帧率就是通过时间。
function vkmh = computeSpeed(frameIn, frameOut, distanceMeters, fps) % computeSpeed 通过两线帧号差和物理距离计算速度 % frameIn 进入第一条检测线的帧号 % frameOut 离开第二条检测线的帧号 % distanceMeters 两条检测线之间距离,单位米 % fps 视频帧率 dt = (double(frameOut) - double(frameIn)) / fps; if dt <= 0 vkmh = NaN; return; end vkmh = (distanceMeters / dt) * 3.6; end调用时对每个轨迹维护两个标记:是否已跨过第一线、是否已跨过第二线。跨第一线时只记录帧号,跨第二线时调用computeSpeed得到该车的行驶速度。注意用质心位置穿过线的瞬间记录帧号,而不是等外接框完全进入画面后再记录,这样两线之间的距离定义更准确。
质心位置在连续几帧间会有几个像素的抖动,单帧触发产生的速度噪声很容易超过 ±3km/h。常见做法是对同一个轨迹的质心坐标做三帧滑动平均,再用平滑后的质心判断是否跨线。更简单的替代是记录最近五帧中质心距离检测线最近的那一帧作为跨线帧,也能压低抖动影响。
4.3 时间平均速度 vs 空间平均速度
mean(vList)是大多数人第一反应的平均速度写法,但它是时间平均速度。假设两辆车通过同样 100 米路段,一辆用 5 秒,另一辆用 10 秒,算术平均速度是 (20+10)/2=15 m/s,但路段上所有车的总行驶时间是 15 秒,总距离 200 米,空间平均速度只有 13.33 m/s。交通工程里说的平均车速通常指后者,因为它更准确地反映道路拥堵程度。
MATLAB 里写清两种平均速度很有好处:
% 单车速度序列,单位km/h vList = [36.2, 38.5, 37.1, 42.0]; % 时间平均速度:单车速度的算术平均 temporalMean = mean(vList); % 空间平均速度:路段长度 / 平均通过时间 % 如果每辆车通过同一固定距离L,先由速度反推通过时间 fixedDistanceKm = 0.1; % 两线之间距离,单位km hoursPerVehicle = fixedDistanceKm ./ (vList / 3.6); spatialMean = fixedDistanceKm / mean(hoursPerVehicle);这里fixedDistanceKm ./ (vList / 3.6)其实改对了。注意若把速度从km/h换算成m/s再算时间,距离也要用m,不要混用单位。实际 GUI 中建议两个值都展示,并在输出框标注“时间平均速度”和“空间平均速度”,因为答辩或报表里经常会被追问差别。
5. 用 App Designer 把检测做成 GUI
5.1 GUI 需要哪些控件
传统 GUIDE 已经过时,新项目应从 App Designer 起步。车辆检测系统的界面可以做成左侧视频显示、右侧参数控制的结构,最少需要这些控件,功能与名称对应关系如下:
| 控件类型 | 名称 | 作用 |
|---|---|---|
| UIAxes | DisplayAxes | 显示原视频叠加检测框 |
| UIAxes | FlowAxes | 显示车流量-时间曲线 |
| Button | OpenVideoButton | 打开视频文件 |
| Button | StartButton | 启动检测 |
| Button | StopButton | 暂停或停止 |
| EditField | DetectLineYField | 设置检测线坐标 |
| EditField | ThresholdField | 设置最小车辆面积 |
| Label | VehicleCountLabel | 显示累计车流量 |
| Label | AvgSpeedLabel | 显示平均速度 |
App Designer 的自动布局比 GUIDE 好用,但要注意回调函数里写app.xxxField.Value才能取到控件值,不要用eval或get,否则代码可读性会明显下降。核心算法应该全部放入独立函数,GUI 只负责读取参数和显示结果。
5.2 用 timer 驱动视频处理循环
直接在按钮回调里写while循环会导致界面卡死,因为 App Designer 的回调过程是单线程的,等待循环结束时界面无响应。常见做法是用一个timer对象按帧周期触发回调,每触发一次处理一帧。
function StartButtonPushed(app, ~) % 读取参数 app.detectLineY = str2double(app.DetectLineYField.Value); app.minBlobArea = str2double(app.ThresholdField.Value); app.frameCount = 0; app.vehicleCount = 0; % 建timer:固定速率触发,每秒触发次数约等于视频帧率 app.detectionTimer = timer( ... 'ExecutionMode', 'fixedRate', ... 'Period', 1 / getFrameRate(app.videoReader), ... 'TimerFcn', @(~, ~) app.processFrame()); start(app.detectionTimer); end function processFrame(app) if ~hasFrame(app.videoReader) stop(app.detectionTimer); return; end frame = readFrame(app.videoReader); app.frameCount = app.frameCount + 1; % 核心检测函数,返回计数和速度 [mask, bboxes, newCount, speedList] = ... detectVehicles(frame, app.detectLineY, app.minBlobArea, app.frameCount); % 更新显示 app.VehicleCountLabel.Text = sprintf('车流量: %d', newCount); app.AvgSpeedLabel.Text = sprintf('平均速度: %.1f km/h', mean(speedList)); endtimer的Period直接依赖视频帧率,但实际摄像头帧率可能不准,TimerFcn处理时间长过周期时,Windows 会逐渐积压回调。更稳的写法是在processFrame里用tic/toc计算当前循环消耗时间,动态调整 timer 的Period,或者干脆不用 timer,改用drawnow配合pause的 while 循环,在停止按钮回调里置一个stopFlag结束循环。两种方式都能跑,timer 方式更接近工业上位机里的定时采样思路。
5.3 在 GUI 上叠加车辆框、检测线和实时指标
UIAxes上显示图像有两种方式:imshow(app.UIAxes, frame)每帧都调用一次会比image函数慢很多。常见做法是第一次创建图像对象,之后只更新CData。
% 初始化显示:app.frameImage 保存图像句柄 if isempty(app.frameImageImage) app.frameImageImage = imshow(frame, 'Parent', app.DisplayAxes); else app.frameImageImage.CData = frame; end叠加检测线用line在同一个UIAxes上画一条红色水平线:
hold(app.DisplayAxes, 'on'); line(app.DisplayAxes, [1 size(frame, 2)], [app.detectLineY app.detectLineY], ... 'Color', 'r', 'LineWidth', 1.5); hold(app.DisplayAxes, 'off');检测框用rectangle或insertShape都可以。insertShape会生成新图像,然后imshow更新,图像帧率会下降;直接对axes画矩形则只更新少量线条属性,性能更好。实时车流量曲线用plot的方式类似,先保存数据,每 30 帧刷新一次曲线即可,不要每帧重绘全部历史点。
6. 调参与验证:让 MATLAB车辆检测在不同场景下少出错
6.1 用已知速度的合成视频验证算法
先造一段运动矩形视频,验证速度和计数链路是否自洽。下面的代码生成一个宽度固定、每帧移动 3 像素的白色矩形:
videoOut = VideoWriter('syntheticTest.avi'); open(videoOut); for k = 1:100 frame = zeros(480, 640, 3, 'uint8'); % 矩形从第50列开始,每帧右移3像素 frame(200:280, 50 + 3*k:100 + 3*k, :) = 255; writeVideo(videoOut, frame); end close(videoOut);把这段视频丢进检测系统,应该得到矩形从第一线到第二线的帧号差。若两线实际像素距离为 60,帧率 25fps,则理论速度是60 / (frameOut - frameIn) * fps像素/秒。验证时把输出速度再反算成像素位移,确认和实际每帧 3 像素一致。这个方法能快速排除标定错误,再切换真实视频时背景复杂度会造成偏差,但至少能证明代码主流程没问题。
6.2 三个高频坑
第一个高频坑是阴影把两辆车连成一片。树影和人影产生的 foreground mask 常和车辆粘连,开运算核不够大时会直接把两个目标合并。解决方法是先对 mask 做一次bwareaopen,再用regionprops检查长宽比,过滤掉明显横向拉长的区域。也可以在 HSV 空间分析色度,阴影区域饱和度和背景接近,可做亮度比例阈值剔除。
第二个高频坑是车辆在检测线上停车,或者极慢速通过。背景减除的LearningRate偏大时,车辆会被逐渐吸收进背景模型,导致速度计算只覆盖到车辆进入的前半段,计算出的速度偏低。遇到这种情况要调低LearningRate到 0.002 左右,并且在轨迹跨第一线后设置一个超时上限,比如 5 秒内未跨第二线就判为停车,不计入平均速度。
第三个高频坑是检测线位置靠近画面边缘。车辆刚进入画面时 blob 面积小、质心不稳定,跨线帧号可能提前或滞后好几帧,速度误差被放大。检测线应放在画面宽度中后段,不要放在画面顶部 20% 或底部 10% 区域。多车道场景还要注意,一条检测线只能统计一个方向,如果画面里存在双向车流,必须给每个方向分别配置一对进出检测线,再用质心跳变方向区分。
6.3 把算法写进函数文件,让 GUI 和脚本结果完全一致
最后建议把检测、计数、速度计算都封装成普通函数,而不是塞在 GUI 回调里。例如detectVehicles(frame, detectLineY, minBlobArea, frameCount)接收一帧画面和参数,返回计数结果和速度数组,这样 GUI 里的 timer 和命令行批处理脚本都能调用同一份逻辑。验证时先跑一段视频的批处理版本,看控制台输出的数据是否合理,再打开 GUI 对同一段视频运行,两个结果应该完全一致。如果 GUI 界面展示了不同数据,通常不是算法问题而是 timer 回调过程中frameCount和vehicleCount的变量更新顺序错了。调整时应让状态变量全部保存在 App 对象里,例如app.frameCount、app.vehicleCount,避免回调闭包意外覆盖。
本文还有配套的精品资源,点击获取