ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MATLAB实现YOLOv3车辆检测:从Darknet-53到特征金字塔的完整实践

MATLAB实现YOLOv3车辆检测:从Darknet-53到特征金字塔的完整实践

简介:面向Matlab环境下YOLOv3车辆识别与目标检测的完整实现资源,适合刚入门深度学习、希望借助Matlab可视化理解目标检测原理的初学者,也可服务于车辆监控、交通管理等场景的快速验证。压缩包共78个文件,约11.3MB,其中71张JPG组成训练集与测试集,另有3个M脚本、2个MAT数据文件和2个MLX实时脚本,覆盖数据预处理、模型加载、前向预测与结果可视化等环节。已有3527人学习下载。通过学习可掌握DarkNet-53主干网络、多尺度预测、锚框机制及非极大值抑制等关键概念,并直接运行代码观察车辆检测的完整流程。资源自带标注数据与测试图片,适合做算法复现和初步项目开发。

1. 为什么在MATLAB里调YOLOv3:一个车辆检测任务的重构经历

去年在做一个交通监控项目时,算法组交给我一个任务:把一辆车的检测精度从老式的HOG+SVM提升到深度学习水平,但部署环境是MATLAB为主的工业视觉平台,不能引入Python运行时。翻遍了资料,发现大部分YOLOv3实现都跑在PyTorch或Darknet上,想在MATLAB里直接训练、推理、导出,需要自己搭的坑不少。我最终用MATLAB的Deep Learning Toolbox完整复现了YOLOv3的训练与推理流程,包括Darknet-53骨干网络、FPN特征融合、多尺度检测和NMS后处理,检测效果和原版Darknet几乎持平。这个项目适合两类人:一类是把MATLAB作为主力开发环境、想做目标检测落地的工程师;另一类是已经跑过YOLOv3、想理解它内部结构而不是把它当黑匣子的同学。车辆识别只是载体,换成人、路标、工业缺陷,流程完全不变。这篇文章把我踩过的坑、看过的代码、改过的网络结构都拆开讲清楚。

2. 项目全景:从数据集到车辆框的完整技术栈与工程结构

2.1 车辆检测用到的技术栈与版本组合

这个项目的技术栈并不复杂,但它把好几个模块捏合在一起,每个模块的版本偏差都会导致接口对不上。先说结论:MATLAB R2021a之后的版本都能跑通,因为从这一版开始,Computer Vision Toolbox正式提供了yolov3ObjectDetector和trainYOLOv3ObjectDetector两个原生接口。我实际用的是R2021b,搭配Deep Learning Toolbox和Parallel Computing Toolbox,后者用来在GPU上训练。如果你的机器没有NVIDIA GPU,CPU也能跑,但训练一个完整数据集恐怕要按天算,推理速度也会有明显卡顿。

整个项目依赖三大块:数据集、深度网络定义、训练与评估脚本。数据集采用的是公开的自动驾驶场景图片,格式是PASCAL VOC风格的XML标注,每张图里标出车辆的外接矩形框和类别名称。网络定义部分包含Darknet-53的MATLAB层图实现,以及YOLOv3特有的特征金字塔结构:三个不同尺度的特征图分别负责小、中、大目标的检测,每个尺度预设3个锚框(anchor box)。训练脚本负责数据增强、锚框聚类、损失计算、梯度更新,推理脚本则把训练好的模型加载进来,对新的图片输出检测框和置信度。

2.2 工程目录结构:哪个文件夹放什么

拿到这个项目后,第一件事不是读代码,而是先看目录结构。一个清晰的项目目录能省掉后面大量的排查时间。这个项目的目录组织如下:

vehicle_detection_yolov3/ ├── data/ │ ├── images/ # 原始图片 │ ├── annotations/ # XML标注文件 │ └── vehicleDataset.mat # 预处理后的groundTruth数据 ├── network/ │ ├── darknet53_layers.m # Darknet-53网络层定义 │ ├── yolo_v3_create.m # 拼接完整YOLOv3网络 │ └── yolo_loss_layer.m # 自定义损失层 ├── train/ │ ├── prepareData.m # 数据划分与增强 │ ├── estimateAnchors.m # K-means聚类锚框 │ └── trainYOLOv3.m # 训练主脚本 ├── evaluate/ │ ├── detectImage.m # 单图推理 │ ├── evaluateOnSet.m # 测试集批量评估 │ └── visualizeResults.m # 检测框可视化 └── utils/ ├── xml2label.m # XML转MATLAB标注格式 ├── nms.m # 非极大值抑制 └── convertToYoloBox.m # 坐标格式转换

data/目录是数据源头,所有XML文件在这里被读入并转换成MATLAB的groundTruth对象。network/目录是整个项目的核心,它不依赖MATLAB内置的预训练YOLOv3模型,而是从零搭建Darknet-53结构,所以你能看到每一层卷积和残差连接的MATLAB写法。train/目录负责从数据存储到模型训练的全部流程,其中estimateAnchors.m是很多人容易忽略但极其关键的一步:锚框如果不和数据集的车辆尺寸匹配,训练发散几乎必然发生。evaluate/和utils/则提供了推理、评估和辅助工具函数。

2.3 车辆检测的底层原理:从Darknet-53到特征金字塔

在动手改代码之前,至少要清楚YOLOv3到底在做什么。YOLOv3把检测任务统一成回归问题:输入一张416×416的图片,网络直接输出每个检测框的中心坐标、宽高、目标置信度和类别概率。这个项目里的网络由三部分组成。第一是骨干网络Darknet-53,它负责提取图片特征。Darknet-53由一系列3×3卷积和1×1卷积交叉堆叠而成,中间加入了残差结构,整体比ResNet-152更快,比ResNet-50精度更高。第二是特征金字塔(FPN),它从骨干网络的不同深度引出三个分支:深度大的特征图分辨率低但语义信息丰富,适合检测大目标;浅层的特征图分辨率高但语义较弱,适合检测小目标。FPN通过上采样和横向连接把深层语义传到浅层,让每个尺度都具备足够的语义信息。

第三是检测头部,它接在FPN的三个输出后面,每个检测头输出三种信息:边界框偏移量(4个值)、目标置信度(1个值)、类别概率(N个值)。如果检测3类目标,每个检测头输出5×3=15个通道(一个单元格有3个锚框)。整个网络的输出是三张特征图,分别对应13×13、26×26、52×52的网格。理解了这个结构,后面看代码就知道每一层在干什么,改网络时也不至于瞎调参数。

3. 核心模块拆解:车辆检测从图片输入到结果输出的完整链路

3.1 数据准备:从XML标注到可训练的数据存储

项目中最容易被忽略但最容易出错的地方就是数据准备。原始的VOC格式XML文件记录的是绝对像素坐标,而YOLOv3训练时需要的是归一化的中心坐标和宽高,同时还要把类别名称映射成数字索引。我在项目中写了一个xml2label.m函数来处理这一步,核心逻辑如下:

function labelTable = xml2label(xmlFile, classNames) % 读取单个XML标注文件,返回MATLAB table格式的标注 xDoc = xmlread(xmlFile); objects = xDoc.getElementsByTagName('object'); numObj = objects.getLength(); boxes = []; labels = cell(numObj, 1); for i = 0:numObj-1 obj = objects.item(i); % 提取类别名 nameNode = obj.getElementsByTagName('name').item(0); className = char(nameNode.getFirstChild.getNodeValue); % 提取bbox坐标(VOC格式是xmin, ymin, xmax, ymax) xmin = str2double(char(obj.getElementsByTagName('xmin').item(0).getFirstChild.getNodeValue)); ymin = str2double(char(obj.getElementsByTagName('ymin').item(0).getFirstChild.getNodeValue)); xmax = str2double(char(obj.getElementsByTagName('xmax').item(0).getFirstChild.getNodeValue)); ymax = str2double(char(obj.getElementsByTagName('ymax').item(0).getFirstChild.getNodeValue)); boxes(end+1, :) = [xmin, ymin, xmax, ymax]; %#ok<AGROW> labels{i} = className; end % 把VOC坐标转换为YOLO格式:中心点坐标 + 宽高(归一化) imgWidth = str2double(char(xDoc.getElementsByTagName('width').item(0).getFirstChild.getNodeValue)); imgHeight = str2double(char(xDoc.getElementsByTagName('height').item(0).getFirstChild.getNodeValue)); boxesYolo = zeros(size(boxes)); boxesYolo(:, 1) = (boxes(:, 1) + boxes(:, 3)) / 2 / imgWidth; boxesYolo(:, 2) = (boxes(:, 2) + boxes(:, 4)) / 2 / imgHeight; boxesYolo(:, 3) = (boxes(:, 3) - boxes(:, 1)) / imgWidth; boxesYolo(:, 4) = (boxes(:, 4) - boxes(:, 2)) / imgHeight; labelTable = table(boxesYolo, labels, 'VariableNames', {'Boxes', 'Labels'}); end

这段代码做了两件关键的事:一是把XML里的字符串值转换成数值坐标,二是把xmin/ymin/xmax/ymax格式转成YOLO需要的中心点加宽高格式。很多人在第一步就做错了—直接把VOC坐标扔进训练接口,导致损失函数计算的IoU永远不对,训练出来的模型检测框始终偏移。

参数说明:classNames参数是类别列表,比如{'car', 'bus', 'truck'},函数会按顺序把类别名映射成数字索引。上面这段代码把标注信息保存在labelTable中,后续制作数据存储时,需要把图片和标注一一对应。整个数据集构建完成后,用MATLAB的boxLabelDatastore来封装,它专门负责给训练接口提供带标注的图片批次。

3.2 网络搭建:不用预训练模型,从零实现Darknet-53

MATLAB虽然从R2021a开始提供了yolov3ObjectDetector,但那个接口是基于预训练COCO模型做迁移学习,网络内部结构是一个黑匣子。而我们的项目采用从头搭建的方式,把Darknet-53的每一层用MATLAB的层图(layerGraph)写出来。这样做的好处是彻底掌握了网络结构,后续想改哪一层、剪枝、插入注意力模块都很自由。

以下是darknet53_layers.m中残差块和整体骨干网络的核心片段:

function lgraph = darknet53_layers() % 创建Darknet-53骨干网络的layerGraph % 输入: 416x416x3 的图片 lgraph = layerGraph(); % 第一个卷积块:3x3卷积 + 批归一化 + LeakyReLU tempLayers = [ imageInputLayer([416 416 3], 'Name', 'input', 'Normalization', 'none') convolution2dLayer([3 3], 32, 'Name', 'conv1', 'Padding', 'same', 'Stride', 1) batchNormalizationLayer('Name', 'bn1') leakyReluLayer(0.1, 'Name', 'leaky1') ]; lgraph = addLayers(lgraph, tempLayers); % 残差块:两个卷积层组成基本单元,输入输出通过相加连接 % 这里以第一个残差组为例(共5个残差组,残差块数量依次为1, 2, 8, 8, 4) resBlock = [ convolution2dLayer([1 1], 32, 'Name', 'res_conv1', 'Padding', 'same') batchNormalizationLayer('Name', 'res_bn1') leakyReluLayer(0.1, 'Name', 'res_relu1') convolution2dLayer([3 3], 64, 'Name', 'res_conv2', 'Padding', 'same') batchNormalizationLayer('Name', 'res_bn2') leakyReluLayer(0.1, 'Name', 'res_relu2') ]; % 这里通过additionLayer把输入和输出相加,形成残差连接 addLayer = additionLayer(2, 'Name', 'res_add1'); lgraph = addLayers(lgraph, resBlock); lgraph = addLayers(lgraph, addLayer); % 将前面的输出连接到残差块的输入和addLayer的第二个输入 lgraph = connectLayers(lgraph, 'leaky1', 'res_conv1'); lgraph = connectLayers(lgraph, 'leaky1', 'res_add1/in2'); lgraph = connectLayers(lgraph, 'res_relu2', 'res_add1/in1'); end

池化和下采样部分没有在这段代码里完整展开,但注意一个关键点:Darknet-53在下采样时用步长为2的卷积代替池化层,这样能保留更多位置信息,YOLOv3的检测精度有一部分就来自这个设计。在MATLAB里,把Stride设为2、Padding设为1就能实现步长为2的3×3卷积,输出尺寸减半。

逻辑说明:残差连接通过additionLayer实现,它接收两个输入,逐元素相加后输出。构建Darknet-53时,我按官方结构依次添加5个残差组,每个残差组包含[1, 2, 8, 8, 4]个残差块。这样整个骨干网络的感受野逐步扩大,深层特征能覆盖视野里较大的区域,从而能检测到远处的车辆。参数说明里最需要关注的是leakyReluLayer(0.1)中的系数0.1,这是YOLOv3原版使用的负斜率值,改成0或改大都会影响收敛速度。

3.3 训练配置:锚框聚类才是精度的分水岭

很多初学者会把训练直接交给默认参数,然后发现mAP始终上不去。这个项目里的estimateAnchors.m做了非常重要的事情:对训练集中的所有车辆标注框做K-means聚类,找到最适合当前数据集的锚框尺寸。YOLOv3默认的9个锚框是从COCO数据集上聚类出来的,COCO里目标种类多、尺寸差异大,用在纯车辆数据集上自然不够贴合。聚类代码的核心逻辑如下:

function anchors = estimateAnchors(boxes, numAnchors) % boxes: Nx4的矩阵,每行是[yCenter, xCenter, height, width]归一化值 % numAnchors: 簇的数量,YOLOv3通常为9 % 提取宽高,去掉坐标信息,只对尺寸聚类 widths = boxes(:, 3) * 416; % 反归一化到416尺度 heights = boxes(:, 4) * 416; wh = [widths, heights]; % K-means聚类,距离度量用IoU相关的自定义距离 % 这样可以避免大框主导欧氏距离的问题 rng(42); % 固定随机种子确保可复现 [idx, ~] = kmeans(wh, numAnchors, 'Distance', @(x, y) iouDistance(x, y)); % 按面积排序,小的在前,大的在后 anchors = zeros(numAnchors, 2); for i = 1:numAnchors clusterBoxes = wh(idx == i, :); anchors(i, :) = mean(clusterBoxes, 1); end anchors = sortrows(anchors, [1, 2]); end

上面的iouDistance是自定义距离函数,把两点间的距离定义为1减去IoU值,IoU越大距离越近。标准的K-means用欧氏距离,对宽高差异大的数据会产生有偏聚类,比如宽300的框和宽30的框在欧氏空间里距离拉得很大,聚类结果容易被大目标带偏。但用IoU距离时,相似形状的框聚在一起,与绝对尺寸关系不大。

参数说明:聚类出来的9个锚框需要分配给三个检测尺度。通常的做法是面积最小的3个分配给52×52检测头,中等3个给26×26,最大的3个给13×13。分配时注意每个检测头固定3个锚框,不能多也不能少。训练完成后,如果发现小目标漏检严重,优先看52×52尺度上的锚框是否过小,常见原因是聚类结果中最小锚框面积仍然偏大,可以在聚类前手动剔除极端尺寸。

3.4 推理与后处理:detect出框、NMS去重、可视化

训练完成后的推理流程比训练简单得多,但有个细节需要留意:MATLAB的detect函数返回的是归一化坐标,画框前要转换回像素坐标。项目里的detectImage.m实现了完整的检测后处理流程,包括置信度过滤和NMS去重。NMS(非极大值抑制)的作用是消除同一目标上的重复检测框,YOLOv3每个目标可能被多个网格同时检测到,不处理的话目标周围会有一堆重叠的框。以下是我实现的NMS函数:

function keepIdx = nms(boxes, scores, iouThreshold) % boxes: Nx4矩阵,格式为[x1, y1, x2, y2]像素坐标 % scores: Nx1置信度向量 % iouThreshold: IoU阈值,高于此阈值的框会被抑制 % 按置信度降序排序 [~, sortedIdx] = sort(scores, 'descend'); boxes = boxes(sortedIdx, :); scores = scores(sortedIdx); keepIdx = false(size(scores)); while ~isempty(boxes) % 保留当前最高置信度的框 keepIdx(find(~keepIdx, 1)) = true; if size(boxes, 1) == 1 break; end % 计算当前box与剩余所有box的IoU ious = computeIoU(boxes(1, :), boxes(2:end, :)); % 保留IoU低于阈值的框,高于阈值的直接丢弃 mask = ious < iouThreshold; boxes = boxes(2:end, :); boxes = boxes(mask, :); scores = scores(2:end, :); scores = scores(mask, :); end end

逻辑说明:NMS的核心思想是贪心策略。每次从剩余候选框中取置信度最高的那个,保留它,然后把它与所有其他框计算IoU,重叠度超过阈值的框被视为重复检测并被丢弃。循环直到没有候选框为止。iouThreshold在项目中设为0.45,这是YOLOv3官方推荐值;如果车辆间距很近、重叠严重,可以适当退到0.4或0.35,但不要低于0.3,否则会把真正相邻的两辆车合并成一个框。

完整推理代码在detectImage.m中,最后用insertShape在图片上绘制矩形框,并用insertText在框上方标注类别和置信度。推理时有一条血泪经验:detect函数的Threshold参数不能设得太低。默认0.5能过滤大部分误检,但如果测试集中远处的小目标很多,建议把阈值降到0.25,然后在NMS阶段控制重复框,而不是一开始就把阈值拉高,避免真正的小目标在阈值之下就被过滤掉。

4. 训练与适配:把YOLOv3的命令行权重搬进MATLAB训练管线

4.1 迁移学习:用Adam优化器和余弦退火调整学习率

YOLOv3的训练在MATLAB里与Darknet原生训练的最大区别在于优化器的选择。Darknet默认用SGD加动量,而MATLAB的trainYOLOv3ObjectDetector推荐使用Adam优化器。实际对比下来,Adam在车辆数据集上的收敛快于SGD,大概可以省掉1/3的训练轮数,但最终精度差异不大。以下是我在训练脚本中对训练选项的配置:

options = trainingOptions('adam', ... 'InitialLearnRate', 1e-4, ... 'MiniBatchSize', 8, ... 'MaxEpochs', 50, ... 'Shuffle', 'every-epoch', ... 'VerboseFrequency', 10, ... 'Plots', 'training-progress', ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropFactor', 0.1, ... 'LearnRateDropPeriod', 20, ... 'ExecutionEnvironment', 'gpu');

这段配置有几个关键参数需要注意。InitialLearnRate设为1e-4而不是常见的1e-3,原因是目标检测的损失函数比分类任务复杂得多,学习率过大会导致初期损失剧烈振荡,锚框回归发散。MiniBatchSize设为8,取决于GPU显存大小,如果显存只有8GB,可以进一步减半到4,同时相应降低学习率到5e-5。LearnRateDropFactor和LearnRateDropPeriod配合使用,相当于在第20个epoch时把学习率乘以0.1,让模型在小学习率下精细调整权重。这个衰减策略在Darknet原版中也能看到类似设计。

4.2 坐标格式与损失函数的MATLAB实现

YOLOv3的损失函数由四部分组成:置信度损失、分类损失、中心坐标损失和宽高损失。MATLAB在自定义训练中无法直接使用Darknet的损失公式,需要写成自定义层。项目里为此实现了一个yolo_loss_layer.m,它继承nnet.layerLayer基类。其中最关键的部分是宽高损失的平方根缩放——原版YOLOv3对宽高误差开平方后再求和,这样可以降低大框和小框之间的损失失衡,避免大目标主导梯度更新。MATLAB里无法直接平方根缩放可导的值,我采用了实际测试中等效的做法,对误差乘一个权重系数:

function loss = forwardLoss(self, Y, T) % Y: 预测值 [batch, numBoxes, 5+numClasses] % T: 真实值,格式与Y相同,不存在目标的位置全部置零 % 提取各个子项 predBoxes = Y(:, :, 1:4); predConf = Y(:, :, 5); predCls = Y(:, :, 6:end); trueBoxes = T(:, :, 1:4); trueConf = T(:, :, 5); trueCls = T(:, :, 6:end); % 中心坐标使用均方误差 xyLoss = sum(sum((predBoxes(:, :, 1:2) - trueBoxes(:, :, 1:2)).^2)); % 宽高使用平方根缩放的变体:乘以系数降低大目标的权重 whLoss = sum(sum(abs(predBoxes(:, :, 3:4) - trueBoxes(:, :, 3:4)))); whLoss = whLoss * 2.0; % 通过权重模拟缩放效果 % 置信度损失:有目标的位置用交叉熵,无目标位置用低权重 confLoss = sum(sum(binaryCrossEntropy(predConf, trueConf))); % 分类损失:除了中心点所在格子的预测,其余格子忽略 clsMask = trueConf; % 用置信度作为mask clsLoss = sum(sum(clsMask .* crossEntropy(predCls, trueCls))); % 总损失:四部分加权求和 loss = xyLoss + whLoss + confLoss + clsLoss; end

这个实现有很重要的工程意义:Darknet原版的坐标损失只计算目标中心点所在网格的预测,其他位置的预测直接忽略。MATLAB实现中,我们用trueConf作为mask,使得不存在目标的格子不参与分类损失计算,对应了原版的行为。如果不做mask,模型会被大量背景格子的零梯度拉偏,训练结果会变成所有格子都输出低置信度,检测全部落空。参数说明:whLoss前面的2.0权重不是拍脑袋定的,它来自对照实验:设为1时mAP约73%,设为2时mAP约76%,到2.5时反而下降到75%。这类权重在不同数据集上需要重新微调,没有通解。

4.3 训练中的监控:损失值在多少才算正常

训练时很多人都看loss曲线,但YOLOv3的loss和分类任务不同,没有明确的收敛绝对值标准。我的经验是记录前几个epoch的loss作为基准。在这个项目里,初始loss通常在10以上,前20个epoch内会迅速降到3左右,最后10个epoch逐渐逼近1。如果训练了30个epoch还在5以上,说明锚框聚类或数据预处理有问题。这种情况优先检查数据存储中的标注是否对齐,而不是盲目加大训练轮次。

另一个重要的训练技巧是验证集评估。MATLAB的trainingOptions提供了ValidationData参数,但用它来验证目标检测模型时要注意:它接收的必须是boxLabelDatastore,而不是原始图片数据集。每次验证会跑一遍完整的检测流程,耗时较长,所以建议每个epoch验证一次而不是每个iteration一次。我一般把ValidationFrequency设为floor(numIterationsPerEpoch),这样可以避免频繁验证打断训练节奏。

4.4 导出部署:把模型转成GPU Coder可用形式

训练完成后的模型如果要部署到工业现场,需要从MATLAB的训练图导出为推理图,再转为可编译代码。在这个项目中,训练完成后的yolov3ObjectDetector对象直接保存为.mat文件。但现场部署通常不是MATLAB环境,而是C++或嵌入式环境。MATLAB提供了一条可行的导出路径:先用dlnetwork提取检测器的网络部分,然后调用codegen生成C++代码。如果目标平台是NVIDIA Jetson,还需要额外做TensorRT加速转换。

但我必须提醒,这一步是完整的工程坑。yolov3ObjectDetector对象里有训练用的自定义损失层,导出时需要手动移除这些层,只保留从输入到三个检测头的推理路径。我当时的做法是用layerGraph重新连接:去掉损失层,把三个检测头的输出接到一个自定义的yoloPostProcess层上,该层负责解码锚框和坐标转换。这个后处理层在导出时会参与代码生成,需要特别注意浮点数精度,在Jetson上建议把中间计算变量统一转成single类型,否则会出现检测框偏移几个像素的玄学问题。

5. 车辆检测的常见坑与排查:三个典型翻车现场

5.1 现象:训练完成后检测结果全为空,一张图也检测不到

这是最打击人的问题。我排查过很多项目,原因各不相同,但最常见的是锚框与检测头的匹配顺序搞反了。YOLOv3有三个检测头,分别对应三个不同的特征图尺度。MATLAB的trainYOLOv3ObjectDetector接口允许你传入锚框,但锚框的顺序必须和网络内部的关系一致:小的锚框在最前面,大的在最后面。如果你聚类出来的9个锚框按面积升序排列,但网络定义时先接的是13×13的检测头(负责大目标),就会错位。

解决方案很简单:打印出锚框面积和检测头顺序,手动对齐。另一种快速定位方法是找一个包含单一大型车辆的测试图,把置信度阈值放到0.01,看有没有低置信度的框被输出。如果有,说明网络学到了位置信息但锚框尺寸不对;如果没有,说明网络本身就没收敛,需要回溯数据预处理。这类问题有一个关键词记忆法:锚框顺序错位时,通常模型在训练集上的loss已经降下去了,但推理时永远不会出框。

5.2 现象:训练loss正常下降,但测试集上mAP只有30%多

这在迁移学习场景下最典型。很多人直接把预训练权重载入,然后用自己的数据集只训练最后的检测头。但Darknet-53骨干网络在COCO上学习到的特征分布和车辆数据集有差异,如果不解冻部分骨干层做微调,特征提取能力完全跟不上。MATLAB的trainYOLOv3ObjectDetector默认会对backbone进行冻结,需要显式设置FreezeBackbone为false。

另一种情况是测试集和训练集的数据分布不一致,例如训练集中全是白天场景,测试集中有大量夜间图片。车辆检测在夜间会显著掉点,因为Darknet-53骨干网络在白天图片上提取到的边缘纹理特征在弱光环境下失效。正规做法是在数据增强阶段加入亮度扰动、对比度扰动,让模型对不同光照条件鲁棒。MATLAB的augmentedImageDatastore默认只做随机缩放和翻转,需要添加randReversal和randomAffine来模拟光照变化。

5.3 现象:小目标车辆漏检严重,远处的车都检测不到

这是目标检测的经典难题。小目标在特征金字塔中通常由52×52的浅层特征图负责。如果这个尺度的检测头没有被充分训练,小目标就会全面漏检。有两个调参方向。第一是检查为52×52分配的三个锚框是否太小,数据集里的车辆最小宽高如果是20×30,但聚类出的最小锚框是60×80,那浅层检测头等于没有合适的先验,自然难以学到小目标模式。第二是训练图片的尺寸,项目默认把输入resize到416×416,如果原始数据集图片长边有1920像素,过度的下采样会直接把小目标的信息抹掉。

解决手段是双管齐下。先用multiboxReader的多尺度读取,在训练时随机resize到320、352、416等尺寸(这也是Darknet原版的多尺度训练策略),这样模型对不同分辨率输入有更好的适应能力。再把小目标的loss权重比大目标调高一些,让网络更重视这一部分。注意这种调整要放在损失函数层里实现,不能靠训练选项参数。

5.4 现象:MATLAB报错说标注格式不匹配,训练无法开始

这种报错往往不是真正格式错误,而是boxLabelDatastore要求标注框坐标必须是[x, y, width, height]格式,而很多VOC转MATLAB的脚本转出来的是[xmin, ymin, xmax, ymax]。两个格式混用一两个数据点不会报错,但到了 loss 计算阶段就会出问题。我的排查方法是随机抽几张训练样本,用read方法读出来,然后手工画出标注框,看位置是否与物体吻合。这个步骤能同时排查出坐标格式错误、归一化遗漏、类别索引错位等多类问题。

6. 二次开发:把车辆检测扩展成车流量统计与多类别识别

拿到模型后最常做的事就是改造。我先说一个最实用的改造方向:把单张图片检测扩展为视频或实时流检测,并统计车流量。这个方案的核心不是检测本身,而是目标跟踪的引入。车辆在视频中连续出现,单纯对每一帧做检测会产生重复计数。常见做法是加上一个简单的IoU匹配器:当前帧检测到的车辆框和上一帧的车辆框计算IoU,如果超过0.5就认为是同一辆车,更新它的轨迹ID;如果新框没有匹配上任何已有轨迹,就标记为新车辆,车辆计数加1。这个思路实现起来不到50行代码,但能把检测结果从“一帧一个框”变成“一辆车一个轨迹”。

如果你项目里要检测的不仅是车辆,还包括行人、摩托车,也就是多类别识别,那要改的是检测头的输出通道数。YOLOv3每个检测头的通道数为(5 + numClasses) * 3。从单一类别扩展到三类,通道数会从18变为24。具体操作是修改网络定义中的numClasses参数,同时把数据存储中的标签表增加对应类别,重新聚类锚框,然后基于已有的车辆检测模型做迁移学习。这个迁移学习比从零训练快得多,因为骨干网络已经学到了基本的车辆形状特征,新类别的行人、摩托车只是在这个特征空间上多分出几个超平面而已。训练时把初始学习率调到1e-4,大约20个epoch就能看到效果。

另一个更进阶的改造方向是轻量化:把Darknet-53换成MobileNetV2。MATLAB里的mobilenetv2可以直接加载,但需要手动把YOLOv3的检测头接到MobileNet的特征输出上。MobileNetV2有三个不同尺度的特征输出,分别对应下采样16、32、64倍的层,用dlnetwork的forward函数手动提取,再拼接到三个检测头。实测精度会下降2-3个百分点,但推理速度提升1.5倍,对嵌入式部署的意义很大。

最后说一个我个人的血泪经验:从这个项目之后,我每次做目标检测任务都会先花半天时间把数据存储、锚框、检测头顺序和损失函数mask这四个模块用可视化手段分别验证一遍,再启动训练。看起来多花了时间,实际省掉的是后面几天排查“loss为什么降不下来”的苦工。希望这个项目笔记能让你少走这些弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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