最近在跑一个辅助驾驶预研项目,核心任务是做图像语义分割。服务器上推的模型精度尚可,但一挪到算力有限、功耗有硬指标的边缘板卡上,基本就跑不起来。于是我把骨架换成了 MobileNetV1,动手搭了一套轻量级分割网络,在自动驾驶场景数据集上完成训练和推理,最终在板卡上做到了接近实时的效果。这篇文章是整个过程的完整学习记录,包含原理拆解、网络改造、训练调参、指标评估和避坑经验。内容适合两类人看:一类是刚接触轻量级分割网络、想理解 MobileNetV1 到底怎么用的人;另一类是已经会调网络、但希望在自动驾驶图像数据上少走弯路的人。我会把能直接抄作业的配置和代码片段都写出来,也会交代清楚每个关键选择背后的原因。
1. 项目概述:MobileNetV1 和自动驾驶语义分割是怎么凑到一起的
1.1 语义分割在自动驾驶里的定位
自动驾驶或者辅助驾驶系统对“周围环境理解”的需求,和普通图像分类、目标检测都不太一样。分类只告诉你图片里是什么物体,检测会进一步给出物体的矩形框,但车辆实际行驶时,我们需要知道边界线在哪里、可通行区域在哪里、行人占住了哪块路面。这些信息本质上都是像素级的,每一个像素都要被归类到某个语义类别,这就是图像语义分割要做的事。
语义分割在自动驾驶里的直接用途包括:可行驶区域的像素级提取、车道线边界检测、近处障碍物轮廓提取,以及视觉感知模块中给后续决策规划提供的前融合输入。如果只看准确率,现在很多大模型的分割效果确实惊人,但实车场景更讲究“在有限算力下做到尽可能准、足够快”。这也是我会从 MobileNetV1 入门的原因。
1.2 为什么偏要选 MobileNetV1
MobileNetV1 不算最先进的结构,但它最干净。它把“轻量化”这件事讲得清清楚楚:用深度可分离卷积代替标准卷积,用宽度乘数和分辨率乘数控制模型规模和计算量。这种直观性非常适合做工程基线。项目初期,我需要先跑通从数据处理到训练再到部署的完整链路,选 MobileNetV1 作为骨干网络,可以极大降低排查问题时的干扰因素。
实际对比中,原来选用的一个通用分割模型,浮点计算量在几十 GFLOPs 级别,边缘板卡跑一帧要几百毫秒;换成 MobileNetV1 作为骨架后,整体计算量能降到原来的十分之一以下,帧率提升非常明显。虽然模型小,但配合合适的数据增强、损失函数和解码器设计,依然能在大多数类别上获得可用精度。
1.3 学习思路与文章结构
这篇文章不会只贴一份训练代码,而是按我的实际推进顺序来写:先说 MobileNetV1 核心原理和它作为分割骨架的天然优劣势;然后讲数据准备与类别设定;接着是网络改造和工程实现细节;再往下是训练调参记录和评测结果;最后整理常见问题与排查方法。这样写的好处是,你既能看懂“为什么这样做”,也能直接拿着中间的关键配置去复现一套类似项目。
2. MobileNetV1 核心原理与结构拆解
2.1 深度可分离卷积:一个笨重的矩阵运算变成两个小运算
标准卷积的计算量公式是:
输出特征图尺寸 × 输入通道数 × 输出通道数 × 卷积核尺寸的平方。
假设输入是 112×112 的特征图,输入输出通道都是 128,卷积核是 3×3,一次标准卷积的计算量大约是:
112 × 112 × 128 × 128 × 3 × 3 = 约 18.5 亿次乘加
这个数量级对嵌入式设备非常不友好。MobileNetV1 的做法是把一个标准卷积拆成两步:第一步是深度卷积,也就是每一个通道单独做一次 3×3 卷积,这一步不跨通道;第二步是逐点卷积,用 1×1 卷积把各个通道的信息融合起来,完成通道维度的组合。
拆完之后,同样条件下深度可分离卷积的计算量是:
深度卷积部分:112 × 112 × 128 × 3 × 3 ≈ 1444 万 逐点卷积部分:112 × 112 × 128 × 128 ≈ 2055 万
加起来约 3500 万,相比标准卷积的 18.5 亿,计算量相差约 8 倍,而精度损失相对可控。这就是 MobileNetV1 最核心的“降本”手段。
2.2 网络整体结构与两个控制参数
MobileNetV1 的主体结构并不复杂:最开始是一层步长为 2 的标准 3×3 卷积,用于快速降分辨率;接着是一系列深度可分离卷积块,逐步把特征图尺寸从输入分辨率降到原图的 1/32,通道数逐步增加。原始结构在最后用全局平均池化和全连接层做分类。做语义分割时,这些分类尾部通常会被拿掉,只保留前面的特征提取部分作为编码器。
结构里有几个值得记录的设计点:
- 每个深度可分离卷积块内部都带 BatchNorm(BN)和 ReLU 激活;
- 步长为 2 的块承担下采样任务,其余块保持分辨率不变;
- 通道数在骨干网络中按 32、64、128、256、512、1024 的路径逐渐增加,保证浅层和高层都有足够的表达能力。
宽度乘数是一个缩放系数,一般记为 alpha。如果把输入通道也等比例缩放,比如 alpha=0.5,那么每一层的通道数都减半,模型参数量和计算量会大幅下降。这个参数非常适合根据板卡算力来调节。分辨率乘数则是直接控制输入图像的分辨率,本质上是把整体计算量按平方关系缩放。对于自动驾驶场景,分辨率的取舍更多要看小目标检测需求,不能无脑降。
2.3 MobileNetV1 作为分割骨架的天然优劣势
MobileNetV1 的优势是计算量小、结构简单、部署友好,劣势也很明显:它下采样到原图 1/32,对分割任务来说空间细节损失太多;同时它没有类似多尺度特征融合的机制,直接拿最后一层特征上采样,小目标和边缘都会比较糟糕。
我的解决办法,概括起来是三条:一是在编码器末端引入空洞卷积或轻量上下文模块,扩大感受野;二是跳跃融合中间层特征,缓解空间信息丢失;三是在损失函数上做文章,用带权重的损失把容易忽略的小类别拉起来。这三条路径在后面的章节里都会展开。
3. 数据准备、类别设定与预处理实验
3.1 数据来源与标注类别设定
这次用的是项目内部采集的道路场景图像,按语义类别划分为 7 类:背景、道路、车道线、车辆、行人、交通标志牌、其他障碍物。类别不能设得太多,因为 MobileNetV1 容量有限,类别过多会导致混淆严重;但也不能太少,自动驾驶至少要能区分道路、车道线、车辆、行人这几个关键对象。
数据规模方面,我整理了大约一万张带像素级标注的图片,按 8:1:1 分成训练、验证、测试三部分。像素级标注通常来自人工标注工具或者由粗标签清洗得到。如果自己标注,要注意边缘细节的一致性,因为分割模型的边界学习极度依赖标签的边界质量。训练前我随机抽取了若干张图做人工检查,发现有些标签里车辆轮廓明显偏大,导致模型倾向于把车辆周边误判为车辆,这种问题即使调损失函数也很难完全纠正。
3.2 数据预处理与增强策略
自动驾驶图像的分辨率通常偏高,比如 1920×1080 或 1280×720。直接整图送入网络不现实,我采用的做法是随机裁剪固定尺寸的训练块。最初用的裁剪尺寸是 512×512,后来考虑到 MobileNetV1 的 1/32 下采样,512 尺寸会使得最高层特征图只有 16×16,对分割来说太粗糙,于是把裁剪尺寸提到 608×608,小目标的召回率明显好转。
预处理管线包括:减去均值再除以标准差、随机水平翻转、随机缩放、随机亮度对比度扰动、随机遮挡模拟。数据增强里最有用的两个操作是随机缩放和随机遮挡。随机缩放能让模型适应不同距离下的目标尺度变化;随机遮挡则是模拟前车挡视野、雨刷遮挡等真实场景,能显著提高模型对局部缺失区域的鲁棒性。
这里有个容易犯的错:语义分割增强必须同时改变图像和标签。用只处理图像、不同步处理标签的方式做增强,训练集和标签会错位,模型等于在学一个错误映射。工程上建议把图像和标签拼接后同步变换,或者直接用支持双通道增强的封装。
3.3 类别不均衡的初步观察与应对
自动驾驶图像中,背景和道路占据了绝大多数像素,车辆、行人、车道线只占很小比例。我统计了一下训练集各类别像素占比,背景加道路大概占了百分之七十以上,车道线占不到百分之一。如果直接拿普通交叉熵训练,模型会倾向于把所有像素都预测成高频类别,结果就是背景、道路的 IoU 很高,但车道线、行人的 IoU 接近零。
应对不均衡,我先做了两件事:一是给损失函数加类别权重,权重值按像素占比的倒数粗略设置;二是把容易混淆的类别单独拆出来看指标,而不是只盯着整体 mIoU。单纯调权重会带来一个新问题:权重过大时,模型可能过拟合到稀疏类别,反而出现大面积误检。最终的权重需要在小范围里做实验,优先级是:漏检最不可接受的对象权重最高,比如行人、车辆;界限模糊但影响较小的类别则少给权重,比如背景。
4. 基于 MobileNetV1 的语义分割网络搭建与实现
4.1 编码器端改造:让 MobileNetV1 长成分割网络的模样
我用 MobileNetV1 的前半部分作为编码器,具体操作是去掉最后用于分类的全局池化和全连接层,保留从浅层到高层的所有特征图输出。编码器输出最高层特征图的大小是输入尺寸的 1/32。如果希望更高的空间分辨率,可以在倒数几个下采样块里把步长改为 1,同时引入空洞卷积来补偿感受野。考虑到板卡算力和实现复杂度,我并没有大改步长,而是采用了下采样保持 1/32、后接轻量上下文模块的方案。
下面是编码器部分的 PyTorch 风格伪代码,其中用到的结构以项目实际复现为准:
class MobileNetV1Encoder(nn.Module): def __init__(self, alpha=0.75): super().__init__() self.conv0 = nn.Conv2d(3, int(32 * alpha), 3, stride=2, padding=1, bias=False) self.bn0 = nn.BatchNorm2d(int(32 * alpha)) # 后续按原结构堆叠 depthwise 与 pointwise 模块 self.stage_out_channels = [64, 128, 128, 256, 256, 512] # 中间模块需按原 MobileNetV1 顺序展开 ... def forward(self, x): feats = [] x = F.relu(self.bn0(self.conv0(x))) feats.append(x) for i, block in enumerate(self.blocks): x = block(x) if i in self.feat_stage_ids: feats.append(x) return x, feats实际工程中,我建议把编码器每个阶段输出的通道数和空间尺寸打出来,确保最高层特征图尺寸与后面的解码器输入对得上。很多初学者在这一步会忽略通道数不一致的问题,导致拼接时报错。
4.2 解码器设计:轻量化上采样与多特征融合
分割网络的解码器要做的事情,是把编码器输出的低分辨率特征逐步恢复到原图尺寸。我采用的是轻量化 FCN 思路:高层特征先经过 1×1 卷积压缩通道数,再与某一层中间特征相加或拼接,最后通过双线性插值上采样到输出分辨率。
具体结构如下:
- 取编码器输出最高层特征,经过一个 1×1 卷积降维到 64 通道;
- 取第 3 阶段或第 4 阶段的中间特征,同样降维到 64 通道;
- 高分辨率特征与上采样后的低分辨率特征逐元素相加;
- 最终使用双线性插值直接上采样到 2 倍或 4 倍分辨率,再接 1×1 卷积输出类别得分。
我实验后发现,融合两层特征已经足够,融合过多特征层反而增加计算量和过拟合风险。MobileNetV1 的浅层特征虽然分辨率高,但语义信息偏弱,直接暴力添加会让解码器变得不稳定。解码器输出层用 1×1 卷积将通道数映射到类别数,本质上是逐像素分类,不额外接全连接层。
4.3 轻量上下文增强模块的加入
为了缓解 MobileNetV1 感受野不够大的问题,我在编码器末尾加了一个轻量上下文模块,思路类似于带空洞卷积的金字塔池化。这个模块不复杂:用三个不同空洞率的 3×3 空洞卷积并行提取特征,再拼接起来。空洞率分别取 1、2、4。空洞卷积能在不降低分辨率的情况下扩大感受野,但空洞率过大时,边缘区域的权重会稀疏,反而损失局部细节,所以空洞率不建议超过 8。
加入这个模块之后,模型对远处小目标和不同尺度物体的鲁棒性明显提升,而计算量增加只有约 10%。对于边缘板卡来说,这是性价比非常高的一笔投入。
5. 训练细节、损失函数与调参实录
5.1 损失函数选择与类别权重设置
训练阶段我用了带类别权重的交叉熵作为主损失。类别权重初始按像素占比倒数的 log 值做软化处理,避免权重差异过大导致训练震荡。类别权重的设置方法如下:先统计每个类别在训练集中的像素占比 p,权重取 1 / (p 的平方根)。对于占比极低的车道线,直接给一个手动上限,防止模型疯狂误检。
除了类别权重,我还加了一个辅助损失:在解码器的中间分支上计算交叉熵,权重设置为主损失的 0.4。这个辅助损失的作用相当于给网络增加一条短路径,帮助梯度更快传到编码器低层,对 MobileNetV1 这种较浅的网络尤其有帮助。实际操作中,辅助损失可以明显缩短收敛时间,但要注意如果权重设置太高,主损失会被干扰,最终精度反而下降。
在考虑是否使用 Lovász 或 Dice 损失时,我做了对比实验。这类以 IoU 为导向的损失对小类别更友好,但训练初期梯度有时不稳。我的建议是:先用带权重的交叉熵把模型训练到收敛,再用 IoU 类损失做微调,这样比一开始就换复杂损失更容易稳定收敛。
5.2 优化器、学习率、批量大小与混合精度
训练配置如下:
优化器:SGD momentum:0.9 weight decay:4e-5 初始学习率:0.01 批大小:8 训练轮数:120 学习率策略:poly 衰减,power=0.9选择 SGD 而不是 Adam,是因为语义分割任务在轻量骨干上配合 SGD 更容易取得稳定且可复现的结果。Adam 前期收敛快,但后期精度往往不如调好学习率的 SGD。学习率初始值不要设太高,我一开始用 0.1,结果前 5 个 epoch 损失直接发散。poly 衰减策略在每个迭代中缓慢降低学习率,比阶梯式下降更平滑,适合分割这种需要长时间精细训练的模型。
批大小取 8 是因为板卡显存有限,但批大小过小会导致 BN 统计不稳定。MobileNetV1 的网络规模不大,即使在 512×512 输入下,批大小为 8 也能正常训练。如果显存溢出,建议优先降低裁剪尺寸,而不是强行减小批大小。每个 epoch 结束我都做一次验证集 mIoU 计算,保存验证集 mIoU 最优的权重,而不是最后一次训练的权重。
混合精度训练在高分辨率输入下可以省下不少显存,也明显加快了训练速度。我开启了自动混合精度,并在损失缩放上采用默认策略,没有出现精度异常。需要注意的是,BN 层在混合精度下仍建议使用单精度,防止统计值不稳定。
5.3 训练过程的收敛迹象与调整心得
训练初期,损失从约 1.8 快速下降,20 个轮次之后损失下降速度明显变慢,验证 mIoU 仍在缓慢上升。训练 40 到 60 轮时,验证曲线出现轻微波动,这通常是学习率偏大的信号。我在第 60 轮时把学习率衰减系数稍微加大,曲线再次进入稳定上升状态。
训练过程中最值得注意的一点是:验证集 mIoU 并不是一直单调上升的,在 50 轮后出现过一次轻微下降,如果再继续用原学习率训练,很可能会过拟合。得益于保存最优权重和设置早停策略,我最终拿到了最优权重。实际部署时发现,最终权重在测试集上的表现,比最后一次检查点权重高了不少,所以“保留最优权重”这件事不能省。
典型训练曲线: epoch 10 loss≈0.85 val_mIoU≈0.46 epoch 30 loss≈0.62 val_mIoU≈0.58 epoch 60 loss≈0.48 val_mIoU≈0.66 epoch 90 loss≈0.40 val_mIoU≈0.70 epoch 120 loss≈0.37 val_mIoU≈0.71上面这个 0.71 只是参考值。实际值会因数据、训练配置、解码器设计差异而有明显浮动。如果你复现时结果偏差较大,建议先确认数据和标签对齐,再看类别权重是否合理。
6. 指标评估与实战推理效果
6.1 语义分割性能指标:IoU 与 mIoU 计算讲解
语义分割最常用的指标是 IoU,也就是模型预测区域与真实标签区域的交集除以并集。如果某个类别预测为 0 个像素,IoU 就是 0,所以 mIoU 对不均衡类别非常敏感。逐个类别计算 IoU 后取平均,就是 mIoU。只看整体像素准确率很容易被高频类别掩盖,必须同时看每个类别的 IoU。
我的测试集结果大致如下:
| 类别 | IoU(主要配置) |
|---|---|
| 背景 | 0.91 |
| 道路 | 0.86 |
| 车辆 | 0.74 |
| 行人 | 0.62 |
| 车道线 | 0.48 |
| 交通标志 | 0.66 |
类别 IoU 之间的差距很直观:高频大类别普遍很好,小目标和稀有线特征明显偏弱。车道线只有 0.48,原因是车道线本身细长、像素占比极小,再加上一些阴影干扰。后来我在数据增强里加入对车道线区域的局部过采样,这部分 IoU 涨到了 0.55 左右。
6.2 实测精度与推理效率对比
训练完成后,我在边缘板卡上做了一轮推理测试。为了评估分辨率乘数和宽度乘数的影响,我对比了几组配置。测试条件为单帧图像、不做任何后处理、单线程推理,实际表现会受框架和内存布局影响,以下数据只反映相对趋势:
| 输入尺寸 | 宽度乘数 | 模型参数体积 | 单帧耗时 | mIoU |
|---|---|---|---|---|
| 960×576 | 1.0 | 较大 | 约120ms | 0.72 |
| 768×384 | 0.75 | 中等 | 约80ms | 0.68 |
| 608×320 | 0.5 | 较小 | 约50ms | 0.62 |
这几组数据的意义在于:mIoU 和耗时之间要取得平衡。如果只追求高精度,大输入尺寸加上全尺寸模型在板卡上达不到实时;如果只追求快,小模型又丢失太多细节。实际项目里,我把 768×384、宽度乘数 0.75 作为默认配置,再根据白天夜间场景切换输入尺寸。
推理性能还可以通过将 BatchNorm 层融合进卷积层来提升。BatchNorm 在训练时是独立算子,推理时本质上是对卷积结果做线性变换,可以预先和卷积权重合并,减少一次遍历。很多端侧推理框架会默认做这种融合,如果没有,手动融合也很简单,就是重写一遍卷积的权重和偏置。我在实测中通过这种方式节省了大约 10% 到 15% 的推理时间,且精度完全无损失。
6.3 可视化结果与失败模式分析
从可视化效果看,整体轮廓基本正确,道路区域比较平滑,车辆和行人的大体位置能框出来。最容易出问题的是三类:
一是光照剧烈变化区域,比如强逆光下的路面阴影,标签里常把它归为道路,但模型会因为纹理变化误判为障碍物;
二是远近景尺度差异,远处车辆只有十几个像素时,模型经常漏检;
三是物体边缘细化不足,车辆和背景交界处会出现锯齿状边界或小面积空洞。
针对第一类问题,我在数据增强阶段加入更多光照扰动和阴影模拟后有所缓解。针对第二类问题,最好的办法是调整推理分辨率,分辨率越高,小目标表现越好,但代价是耗时增加。针对第三类问题,我尝试过传统图像处理中的条件随机场后处理,虽然边界平滑了,却引入了额外耗时,而且模型本身的 mIoU 提升有限,最终没有采用。如果你对边界质量要求很高,更推荐在损失函数中加入到边界的距离约束,而不是在推理时做额外处理。
7. 常见问题排查与避坑记录
7.1 训练时显存不足怎么办
显存不足是轻量模型也躲不开的问题。我的优先级排查顺序是这样的:先降低输入裁剪尺寸,比如从 768 降到 608;再配合梯度累积,用若干个小批次累积梯度后再更新参数;最后才考虑减少通道数。直接减小批大小会把 BN 统计搞得非常不稳定,所以不要走这条路。
如果显存溢出发生在混合精度开启后,建议检查是否有不支持的算子导致推理过程退回到单精度分支。实际项目里出现过某个自定义模块无法被自动混合精度支持,导致该模块前向计算显存暴涨的情况,换成标准卷积后问题消失。
7.2 模型把所有像素预测成道路
这是类别不平衡的典型症状。第一步检查损失函数中是否真的加入了类别权重,或者权重值是否因为数值计算不当变成了 nan;第二步检查标签格式,部分标签文件由于压缩方式问题,某些像素值被覆盖成默认值,导致模型学到错误信息;第三步再观察验证集各类别的 IoU,如果道路 IoU 很高,而行人车辆 IoU 接近零,大概率是权重和训练策略问题。
出现这种情况时,我建议只保留 3 到 4 个关键类别重新训练一轮,确认类别均衡逻辑正确后再加回其他类别。这样能快速定位问题,避免浪费时间。
7.3 小目标检测不到
小目标在 MobileNetV1 这种轻量骨架上确实吃亏,因为下采样倍数太大。排查思路是这样的:先在可视化结果中看小目标是否有响应但位置偏移,还是完全没有任何响应。有响应但位置偏移,通常属于标签错位、数据增强同步失败或损失函数对小类别关注不足;完全没有响应,则更可能是特征分辨率不够。
针对特征分辨率不够的问题,我做了两个改动:一是把主干中最后一个步长 2 的模块改为步长 1,并在后续卷积中引入空洞卷积;二是在解码器融合时,使用分辨率更高的中间层特征。做完这两点后,远处行人的召回率就有了显著提升。
7.4 边界粗糙和边缘空洞
边界问题很少是单一原因导致的,标签本身边缘不准确、编码器下采样过多、损失函数不关心边界都会造成这个现象。我在项目里用了一个简单有效的组合:在类别权重基础上,对道路、车辆、行人这类有明显边界的目标,额外加上边界区域附近像素的交叉熵权重,相当于告诉模型“你们要多花点容量去学边界”。这个操作不需要改网络结构,只需要预处理标签时生成一个边界权重图,非常轻量。
如果还是不满意,可以尝试把输入分辨率继续提高。边缘细节依赖输入分辨率,MobileNetV1 的浅层特征即使融合进来,也只是缓解,并不能彻底解决。
7.5 训练损失震荡或者不收敛
训练损失震荡最常见的原因是学习率太大。尤其是在刚开始切到自定义数据时,如果数据分布和训练集区分明显,0.01 的初始学习率也可能偏大。我的建议是先用极小数据集、固定学习率 0.001 跑 5 个 epoch,确认损失能稳定下降,再把学习率恢复到正常范围。不要一上来就堆数据,调参和排障的效率会高很多。
还有一种情况是 BN 在高批大小下效果很好,但实际模型部署时遇到与训练分布不同的环境图像,推理结果变差。解决办法是尽量让训练增强覆盖部署时的光照和天气情况,同时推理时尽量保持输入分布一致,不要做额外的归一化方式切换。
7.6 常见问题速查表
| 问题现象 | 优先级排查方向 |
|---|---|
| 显存溢出 | 降低裁剪尺寸、用梯度累积、降通道数 |
| 全图预测成高频类别 | 检查类别权重、检查标签格式、重新验证关键类别数据 |
| 小目标完全无响应 | 减少下采样倍数、增加空洞卷积、融合浅层特征 |
| 边界粗糙 | 加边界权重图、提高输入分辨率、检查标签边界 |
| 损失发散 | 降低学习率、减少批大小扰动、检查数据与标签是否同步 |
8. 最后一些个人经验总结
把这个项目完整跑下来,我最大的感受是:MobileNetV1 不是最强的分割骨架,但它是理解轻量分割网络的绝佳起点。它的设计足够简单,让我能在编码器、解码器、损失函数、训练策略之间快速定位瓶颈;它的计算量足够小,让我有时间反复做实验而不至于被训练成本拖垮。
如果后续还有余力,可以沿着几个方向继续扩展:一是把 MobileNetV1 替换成带倒残差结构的轻量骨干,进一步提高容量;二是引入更精细的边界约束,比如边缘感知损失;三是做剪枝和量化,把模型进一步压缩到可以部署到更低算力的设备上。每一步都不难,难的是搞清楚每一步到底解决了什么问题。这个项目把这些基础问题基本理清了,后面做更复杂的结构时就有底了。