
做自动驾驶环境感知的人应该都有同感网络的精度和车端算力永远在打架。我最近把MobileNetV1重新捡起来目标很直接——在一个算力紧巴巴的嵌入式平台上把像素级语义分割跑起来让模型对着车载摄像头画面把道路、车道线、行人、车辆、建筑、天空这些类别逐像素标出来。这篇文章是我的MobileNet学习记录加落地复盘从深度可分离卷积的原理、编码器的裁剪、解码器的设计到损失函数怎么调、训练中踩过的三个大坑、最后实测的性能数据全部写清楚。如果你正纠结轻量网络做分割怎么选型或者刚入门想搞明白MobileNet到底省在哪里这篇应该能帮你少走不少弯路。先说结论我用MobileNetV1作为编码器接一个自己拼的轻量解码器在某个公开的驾驶街景分割数据集上跑到了70%出头的mIoU在嵌入式板子上能做到28帧每秒左右。这个成绩跟重型网络比不算高但放在功耗和体积约束下已经可以接受。下面进入正题。1. 为什么选MobileNetV1做车载分割精度、时延和功耗的三方博弈1.1 自动驾驶语义分割要满足的三条硬约束很多做算法的同学习惯了在实验室用大显卡训练、大显卡推理到了车载场景才意识到问题完全不一样。自动驾驶的视觉感知链路通常是摄像头采集图像分割模型给出逐像素类别后续模块再结合目标检测、深度估计等信息做决策和规划。这条链路上分割模型往往是最占用算力的一环因为它要对每一个像素做分类计算量天然比检测大。第一个约束是时延。从图像进入相机到车辆做出制动或转向决策整个链路的时延预算通常只有几十毫秒。分割环节分到的时间窗口非常苛刻在嵌入式平台上要跑到每秒15到30帧以上才算安全。这意味着模型不仅要准还得快两者常常互相矛盾。第二个约束是功耗和散热。车端不可能装数据中心级的显卡实际部署用的是低功耗计算板整板功耗往往被限制在几十瓦以内。模型的计算密度一旦上去芯片发热和降频都会让实际帧率大打折扣。所以理论算力和可持续运行的算力是两回事轻量模型在这方面天然占优。第三个约束是稳定性和类别覆盖度。自动驾驶分割面对的不是实验室里那种干净图片而是雨天反光、夜间光照骤变、树影遮挡、多车交叠这些复杂场景。模型要在这些条件下稳定输出不能只靠堆精度。有时候为了让模型在极端场景下不出错宁可牺牲几个点的mIoU。1.2 MobileNetV1在骨干网络里的生态位MobileNetV1是2017年出来的轻量骨干网络核心贡献是把深度可分离卷积做了系统化应用让大幅降低计算量和保持可用精度第一次在产品级任务上同时成立。它没有残差连接结构非常直白一个标准卷积加一堆深度可分离卷积块读论文半小时就能复现。我拿它和几类常见的骨干网络做过对比骨干网络参数量224×224输入计算量结构特点VGG-16约138M约15.5 GFLOPs用大量3×3卷积堆叠参数冗余高ResNet-18约11.7M约1.8 GFLOPs带残差结构收敛稳定MobileNetV1约4.2M约1.1 GFLOPs深度可分离卷积为主无残差这个对比能看出MobileNetV1在计算量和参数量上比传统骨干低了一个数量级。VGG-16虽然也能做分割但推理速度在车端基本不可用ResNet-18是精度和速度的折中但在低功耗设备上依然偏重。MobileNetV1的优势是结构足够简单做量化、做算子融合、改输出步长都很容易而且因为发布早社区积累的预训练权重和部署经验非常成熟。当然没有残差结构也是它的短板。我在训练中明显感觉到它比ResNet更敏感学习率稍大一点loss就容易震荡这个后面会详细说。1.3 我的实验基线和最终成效先交代一下整体方案。我用的数据集是一个公开的驾驶街景分割数据集包含两万张带像素级标签的图片类别覆盖道路、车道线、行人、车辆、交通标志、建筑、天空等19类原始分辨率在1024×512左右。训练、验证、测试按约70%、15%、15%划分模型输入分辨率先按512×256处理后面单独做了分辨率的对照实验。整体架构是把MobileNetV1当作编码器提取特征输出步长控制在16而不是默认的32然后接一个轻量解码器做多尺度融合和上采样。损失函数用带类别权重的交叉熵对车道线这种像素占比极小的类别做了加权。训练用SGD加多项式学习率衰减总共150轮。最终在测试集上mIoU约70.3%像素准确率91.6%模型参数量约3.6M。在嵌入式板子上用批量大小为1做推理能达到28帧每秒在消费级显卡上可以跑到100帧以上。这个数字后面会展开分析这里先给个整体印象。2. 深度可分离卷积MobileNetV1省算力的全部秘密2.1 标准卷积先付出多少代价要理解MobileNetV1为什么快先得算清楚标准卷积的开销。假设输入特征图是H×W×C_in使用N个尺寸为Dk×Dk的卷积核输出特征图是H×W×N那么标准卷积的计算量是计算量 H × W × C_in × Dk × Dk × N这个式子每一项都在相乘意味着输入通道数、输出通道数、卷积核面积、特征图尺寸任何一个变大计算量都跟着成倍涨。以我网络里的一个中间层为例输入56×56×64输出56×56×128用3×3卷积核计算量是56 × 56 × 64 × 3 × 3 × 128 ≈ 2.31亿次乘加这只是几十层网络里的一层整个网络叠起来开销非常可观。更麻烦的是标准卷积核的参数量是C_in × Dk × Dk × N这层就有64×9×128约7.4万个参数。对车端设备来说参数越多内存带宽压力越大功耗也越高。2.2 拆成两步深度卷积加逐点卷积深度可分离卷积的核心思路是别再让每个卷积核一口气同时处理所有输入通道把空间滤波和通道混合拆成两步。第一步叫深度卷积。每个输入通道单独配一个Dk×Dk的卷积核只在自己的通道内做空间滤波通道之间互不干扰。这一步的计算量是H × W × C_in × Dk × Dk第二步叫逐点卷积用1×1卷积把上一步的输出做跨通道线性组合得到需要的N个输出通道。这一步的计算量是H × W × C_in × N两个步骤加起来总计算量变成H × W × C_in × Dk × Dk H × W × C_in × N还用刚才那个56×56×64到56×56×128的例子来算。深度卷积的计算量是56×56×64×9约180万次乘加逐点卷积是56×56×64×128约2570万次乘加加起来约2750万次乘加。跟标准卷积的2.31亿次相比只有大概12%的开销。用公式表示更直观。标准卷积和深度可分离卷积的比值是比值 1/N 1/Dk²当输出通道数很多的时候第一项趋近于零对于3×3卷积核第二项是1/9。所以通常说深度可分离卷积能把计算量降到标准卷积的九分之一左右原因就在这个公式里。参数量的压缩同样明显。这层标准卷积有7.4万个参数深度可分离卷积只有深度卷积的64×9等于576个参数加上逐点卷积的64×128等于8192个参数总共8768个大概是标准卷积的八分之一。2.3 两个超参数如何控制模型规模MobileNetV1还引入了两个超参数让模型可以按需缩放。第一个是宽度因子α作用于通道数。把每层的输入输出通道都乘以α计算量会以α的平方比例下降。比如α取0.5各层通道减半计算量约为原来的四分之一。这个参数对精度的影响比较平滑想在算力和效果之间找平衡点时优先调它。第二个是分辨率因子ρ作用于输入图像尺寸。输入分辨率缩到原来的ρ倍所有层的特征图尺寸跟着缩计算量同样以ρ的平方比例下降。但要注意特征图变小意味着信息量变少小目标更容易丢失在分割任务里ρ过大往往得不偿失。我在这个项目里两个因子都保持为1.0。原因很简单分割任务对空间细节极其敏感缩通道可以缩分辨率会让车道线这类细长目标直接消失。后面对比实验也证实了输入从512×256砍到256×128mIoU掉了将近8个点。2.4 用PyTorch手写一个深度可分离卷积块说了这么多原理直接上代码。PyTorch里实现深度可分离卷积非常简洁关键是深度卷积要用groupsin_ch来让每个通道独立卷积import torch.nn as nn class DepthwiseSeparableConv(nn.Module): def __init__(self, in_ch, out_ch, stride1, ksize3): super().__init__() self.depthwise nn.Conv2d( in_ch, in_ch, ksize, stridestride, paddingksize // 2, groupsin_ch, biasFalse ) self.bn1 nn.BatchNorm2d(in_ch) self.pointwise nn.Conv2d(in_ch, out_ch, 1, biasFalse) self.bn2 nn.BatchNorm2d(out_ch) self.act nn.ReLU(inplaceTrue) def forward(self, x): x self.act(self.bn1(self.depthwise(x))) x self.act(self.bn2(self.pointwise(x))) return x按照原论文的设计每个卷积后面都接BatchNorm和ReLU深度卷积和逐点卷积之间也不例外。这里有两个细节值得注意第一深度卷积本身没有bias因为后面紧跟BatchNormbias会被BN吸收第二groupsin_ch意味着输入有多少通道就分成多少组每一组只做一个通道的卷积这就是深度二字的由来。说实话这段代码看上去简单但它背后代表的是一个非常优雅的思路标准卷积同时建模了空间特征和通道关系深度可分离卷积把这两件事拆开单独做。空间关系由深度卷积负责通道关系由1×1卷积负责互不干扰各干各的最后效果却接近完整卷积计算量却少了一个数量级。3. 把分类网络改造成分割网络编码器解码器搭建实录3.1 分类模型为什么不能直接输出分割图MobileNetV1原本是为图像分类设计的输入一张图最后输出一个1000类的概率分布。中间的特征提取过程逐层降分辨率从224×224一路缩小到7×7最后靠全局平均池化把空间信息全部抹平变成一维向量再做全连接分类。而语义分割要求输出和输入同尺寸的逐像素类别图。问题就出在分类网络习惯性地把所有空间信息都压缩掉了。如果直接把分类网络搬过来网络最后记录的是这张图里有辆车而不是车在图像的哪个区域、边界在哪里。所以改造的核心思路是保留并利用中间那些有空间结构的特征图而不是只用最后一层。3.2 编码器裁剪让特征图保持足够分辨率MobileNetV1默认的网络经过32倍下采样一个512×256的输入到最后只剩16×8这个分辨率对于分割来说几乎不可用。车道线在原始图里可能只有两三个像素宽经过32倍下采样就彻底消失了。我的做法是控制编码器的输出步长。MobileNetV1的降采样分布在几个stride为2的深度卷积层。我把最后一个stride为2的块改成stride为1这样输出步长从32变成16特征图分辨率提升一倍。代价是最后一阶段感受野变小但分割任务本身就很依赖局部细节这个牺牲可以接受。如果你需要更精细的结果还可以把输出步长进一步降到8也就是把两个stride为2的块都改成stride为1。这样空间信息保留更多但计算量和显存占用都会上涨。我实测在512×256输入下输出步长8比16的mIoU只高1个点左右帧率却掉了接近三成所以最终选了16这个平衡点。3.3 解码器设计轻量上采样路径与跳层融合有了编码器输出的深层特征还不够因为深层特征虽然语义丰富但分辨率低、边缘粗糙。要恢复精细的分割边界需要把浅层的高分辨率细节融合进来。我这里设计的解码器走的是FPN风格的轻量路径总共三个关键步骤第一步把编码器输出步长16的深层特征做2倍双线性上采样得到输出步长8的特征图。第二步从编码器中间取一个输出步长8的浅层特征先用1×1卷积把它压到合适的通道数再和上采样后的深层特征逐元素相加。这一步让解码器同时拿到全局语义和局部细节。第三步接两个我自己搭的深度可分离卷积块做特征细化然后直接双线性上采样8倍最后用1×1卷积输出每个像素的类别分数。整个过程没有用反卷积做上采样。反卷积虽然可以学习上采样参数但计算量大而且容易出现棋盘格伪影。双线性上采样配合卷积反而更稳在嵌入式设备上也更容易被加速。解码器的代码骨架大概是这样的class LightDecoder(nn.Module): def __init__(self, low_ch24, deep_ch320, num_classes19): super().__init__() self.reduce_low nn.Conv2d(low_ch, 48, 1, biasFalse) self.fuse DepthwiseSeparableConv(320 48, 64, stride1) self.refine DepthwiseSeparableConv(64, 64, stride1) self.head nn.Conv2d(64, num_classes, 1) def forward(self, deep_feat, low_feat): x F.interpolate(deep_feat, scale_factor2, modebilinear, align_cornersFalse) low self.reduce_low(low_feat) x torch.cat([x, low], dim1) x F.interpolate(self.refine(self.fuse(x)), scale_factor8, modebilinear, align_cornersFalse) return self.head(x)3.4 损失函数与类别不均衡处理语义分割最常用的损失函数是逐像素交叉熵。但驾驶场景里类别分布极度不均衡道路、天空、建筑这些类占据了绝大部分像素而车道线、交通灯、锥桶这类关键目标可能只有百分之一甚至千分之一的像素。如果不加处理模型会倾向于把所有像素都预测成高频类车道线识别率直接归零。我采用了中值频率均衡法来设置类别权重。思路是统计训练集里每个类别的像素占比算出占比的中位数然后对每个类别取中位数除以该类别占比作为损失权重。这样像素占比小的类获得更大的权重模型被迫去关注它们。权重计算的代码大致是import numpy as np pixel_counts np.bincount(all_train_masks.ravel(), minlengthnum_classes) freq pixel_counts / pixel_counts.sum() median_freq np.median(freq) class_weights median_freq / np.clip(freq, 1e-8, None)然后在PyTorch里直接传给交叉熵损失criterion nn.CrossEntropyLoss( weighttorch.from_numpy(class_weights).float(), ignore_index255 )ignore_index255是为了跳过数据集中那些标注不确定的遮挡边缘像素。这些像素位于不同类别物体的交界处标注本身就有歧义让模型去学它们只会引入噪声。设置了忽略之后训练损失变得更稳定边界处的预测也更干脆。4. 训练实录数据、超参与踩坑记录4.1 数据预处理与增强方案训练前我把原始图片统一缩放到512×256。这里有个经验之谈驾驶场景图片通常很宽直接按正方形裁剪会丢掉大量道路信息保持原始宽高比缩放是更好的做法。数据的预处理环节包括去均值和归一化这些常规操作就不多说了重点讲讲增强。我用的增强策略是随机水平翻转、随机缩放0.5到2.0倍后随机裁剪、颜色抖动、亮度对比度调整。每个增强操作都同步作用于标签图保证像素类别和图像内容对齐。有一点特别提醒分割任务里不要对驾驶场景做随机旋转增强。道路场景里天空在上、路面在下这个先验很重要旋转后会出现荒谬的几何关系反而把模型带偏。训练时批量大小设为16输入分辨率512×256单卡大概能装下。如果想要更大的批大小可以先用256×128热身或者用梯度累积。4.2 优化器、学习率策略和训练参数优化器我选的是SGD加动量动量0.9权重衰减1e-4初始学习率0.02。很多入门教程会推荐Adam但在语义分割这类密集预测任务上SGD配合合适的学习率调度往往能到更好的精度尤其是在大尺度数据集上。学习率策略用的是多项式衰减公式是lr base_lr × (1 - iter / total_iters)^power其中power取0.9。这不是我拍脑袋定的而是分割任务里很经典的做法。步长衰减每过一个阶段学习率突然变小训练曲线容易出现震荡多项式衰减让学习率在整个训练过程中平滑下降后期在小学习率下慢慢打磨细节效果通常比固定步长好。训练前500个iteration我做了线性warmup从1e-3逐步升到0.02避免模型一上来就被大学习率冲乱。4.3 踩坑记录一车道线和小目标被下采样吞掉第一次实验我偷懒直接用了MobileNetV1默认的32倍下采样模型在验证集上的mIoU只有64%出头。看可视化结果时发现车道线预测几乎全军覆没远端的交通牌也糊成一团。排查下来原因很清晰512×256的输入经过32倍下采样后特征图只有16×8车道线这种一两像素宽的结构在深层特征里早就没了。这不是训练不充分的问题而是网络结构的信息瓶颈问题。后来把输出步长改成16再加上浅层跳层融合mIoU一下子涨了5个多点。这件事给我的教训是做分割时编码器的输出步长是比骨干网络选型更优先需要决策的参数。4.4 踩坑记录二类别权重设错导致大面积误检中值频率均衡法一开始我没敢用先试了最简单的逆频率权重也就是权重直接取频率的倒数。这导致道路、天空等高频类别的权重被压得极低模型为了满足加权后的损失开始疯狂把道路区域预测成其他类别验证集上道路类的IoU掉了十多个点整体mIoU反而比不加权重还差。后来换成中值频率均衡把高频类的权重只压到中等水平而不是无限压低情况才恢复正常。经验是类别权重本质上是平衡工具不是惩罚工具权重范围控制在0.5到5之间比较安全。把某个类权重压到0.1以下模型就会开始乱来。4.5 踩坑记录三BatchNorm在迁移学习中的表现异常我加载了预训练权重后直接跑了一次验证发现输出几乎全是背景类。第一反应是代码写错了检查了半天才发现问题出在BatchNorm上。预训练权重的BN统计量是基于大规模分类数据集计算出来的而我的输入分辨率、数据分布都发生了偏移模型拿到了一组不适配的统计量输出自然崩坏。解决办法很朴实正常训练就好让BN统计量在训练过程中逐步更新。但我额外做了一件事在加载权重后、正式训练前先在训练集上跑了几百个iteration的前向让BN先吸收一点新数据分布再把gradient打开正式训练。这么做之后前几个epoch的loss下降得更平稳没有出现那种先把loss烧上去再慢慢降下来的浪费现象。5. 实测结果量化指标、运行速度与失败案例5.1 评价指标mIoU、PA、帧率怎么组合使用语义分割最常用的指标是mIoU和像素准确率PA。mIoU对每个类别计算交并比再取平均能公平反映每个类别的表现不会被高频类别掩盖PA只算所有预测正确的像素占比容易刷高一张图里道路占了一半全预测成道路PA也能到50%以上。所以看模型好坏主要盯mIoUPA作参考。运行速度方面我统一用批量大小为1的帧率来衡量这更接近实际车载推理场景。测试时会先跑几十次预热让芯片进入稳定状态再取连续运行的均值。要注意的是不同框架、不同加速库下的帧率差异很大横向对比模型时必须在同一软硬件环境里测。5.2 实验数据对比不同输入分辨率和骨干网络我把MobileNetV1方案和另外两个骨干网络做了对照全部用同一个解码器和同一套训练配置配置输入分辨率mIoU(%)PA(%)嵌入式板帧率(FPS)MobileNetV1编码器512×25670.391.628MobileNetV1编码器1024×51274.593.113ResNet-18编码器512×25671.892.011VGG-16编码器FCN-8s512×25672.592.34这组数据能说明几件事。第一MobileNetV1和ResNet-18在512×256下mIoU只差1.5个点但速度是后者的两倍多轻量网络在车载场景的性价比体现得很明显。第二MobileNetV1把分辨率翻到1024×512后mIoU能涨4个点但帧率直接腰斩。这说明模型本身还没有达到精度的天花板瓶颈在输入分辨率。如果算力有余量提高输入分辨率往往比换更重的骨干网络更划算。5.3 可视化效果白天、夜间和十字路口定性地看白天常规道路场景的预测最干净道路边界清晰车辆轮廓基本完整车道线虽然在远景处偶尔断开但在车辆前方40米范围内能保持连续。十字路口的挑战最大车辆转向轨迹、斑马线、对向车流重叠在一起模型偶尔会把两辆并排的车预测成一个连通区域这是语义分割的固有问题——它天然不做实例区分。夜间低照度场景下整体mIoU大概掉了4到5个点。车灯直射区域过曝导致灯周围的行人和路沿被吞掉暗处的深色车辆与路面融合轮廓丢失严重。这类问题单纯靠模型已经很难解决需要配合图像增强或传感器融合。5.4 最典型的失败模式与原因分析我整理了三个反复出现的失败模式。第一个是远端小目标漏检距离超过40米的行人、摩托车经常被漏掉因为它们在特征图里只占几个像素经过下采样后信息几乎丢失。第二个是细长物体断裂车道线、栅栏这类长条形目标在中间偶尔断开本质上是深层特征缺乏足够的局部连续性约束。第三个是类别混淆绿化带和路沿、阴影区域和修补路面经常被混在一起这些类别视觉特征相似仅靠RGB信息难以完全区分。针对细长物体断裂我在后期实验里尝试给深层特征加了膨胀卷积来扩大感受野断裂现象有所改善但代价是计算量上升。对线层部署来说需要仔细权衡。6. 实用经验与下一步扩展方向6.1 如果重新做一遍我会改什么回头看这个项目有几个决定我可以做得更好。第一应该早一点做多尺度训练。我在后期用0.75倍和1.25倍分辨率混合训练mIoU比单一分辨率高了1.2个点这几乎是零成本的提升。第二辅助监督值得加。在编码器的浅层特征上加一个轻量分割头做深度监督可以让浅层学到更多语义信息我用这个技巧把mIoU又提了接近1个点。第三类别权重可以在训练到一半时动态调整先用均衡权重让模型看到所有类别后期再逐步恢复高频类的权重让道路、车辆这些核心类别的精度回来一些。6.2 从MobileNetV1出发下一步看什么学会了V1再去看MobileNetV2和V3会顺畅很多。V2的核心变化是提出倒残差结构先把通道压缩再用大通道数做深度卷积同时把最后一个ReLU换成线性激活解决低维空间信息被ReLU破坏的问题。V3则在结构设计里加入了注意力机制和更精细的激活函数还引入了结构搜索来寻找最优配置。但我的体会是不要急着直接上手V3。V1虽然土但它的结构逻辑最清晰适合理解轻量化设计的根本动机。把V1的深度可分离卷积吃透了看V2的倒残差、V3的注意力都是一层窗户纸。6.3 边缘部署时容易被忽略的工程点最后分享几个工程层面的细节这些在纯算法阶段几乎没人提但到了部署阶段都是实打实的坑。第一量化要小心深度卷积。深度可分离卷积里的depthwise层每个通道只有一组权重统计分布很不稳定INT8量化后误差比普通卷积大很多。如果平台支持优先给深度卷积用逐通道量化不支持的话就把整个模型的第一层和最后一层保留为高精度浮点其余层再做量化。第二上采样别用反卷积。在嵌入式平台上反卷积的算子实现往往效率不高还会引入内存换行开销。双线性上采样加1×1卷积的方案在多数加速库上都有优化实现性能差距能到两倍。第三推理时做水平翻转TTA确实能提升约1个点的mIoU但对实时系统来说推理时间翻倍的代价完全不可接受。车载任务要的是稳定输入下的稳定输出把时间花在提高单次推理质量上比花在TTA上划算得多。做完这个项目最大的感受是轻量网络不是简单的小的ResNet它需要针对任务的特殊性做结构调整也需要在训练细节上付出更多耐心。MobileNetV1虽然已经是好几年前的结构但它把如何用有限算力做好视觉任务这件事讲得非常透彻。如果你也在做车载端的视觉落地我建议从复现一个MobileNetV1分割模型开始把深度可分离卷积、输出步长、类别权重这三件事真正弄明白比直接套一个重型网络有收获得多。