自动驾驶场景里的视觉感知,从来不是“看清楚”那么简单。车要往前走,系统得先回答几个问题:面前是路还是墙?路边停着的是车还是杂物?远处那个晃动的影子是人还是树影?这些问题落到算法层,核心能力就是图像分割——逐像素地判断“这个点属于什么类别”。而Unet这个架构,虽然年头不短,却因为结构简洁、标注依赖少、训练门槛低,一直是做自动驾驶语义分割最顺手的起步方案。这篇文章我会把整套工程链路拆开讲:从任务设计、数据准备、模型搭建、训练优化到部署避坑,把我实际跑过、踩过、修过的细节都写出来,给正在做相关方向的同学一份可以照着操作的完整参考。
1. 为什么自动驾驶需要图像分割,Unet凭什么合适
1.1 从“看到”到“理解”:感知任务的层次划分
自动驾驶的视觉模块,通常分几个层级。最底层是目标检测,输出的是“这个框里是车”的结论;再往上是图像分割,输出的是“这张图里哪些像素属于道路、哪些属于行人”的稠密结果;更复杂的还有实例分割、全景分割,要求区分同一类别的不同个体,并同时处理可数目标和不可数的背景。
这几种任务解决的问题不一样。检测给的是“哪里有物体”,分割给的是“物体的精确边界在哪里”。对于自动驾驶来说,分割的价值主要体现三件事:
道路与可行驶区域判别:车要规划路径,必须先知道哪些区域是路面、哪些是路沿、哪些是草坪。检测框给不了这么细的边界,分割可以。
障碍物轮廓测量:分割出的像素簇比检测框更贴合目标真实外形,配合深度估计或雷达点云,能更可靠地判断障碍物与自车的相对位置。
车道线等细长语义识别:车道线这类目标尺寸小、长宽比极端,检测框很难框准,但分割天然不怕这种结构,一个像素一个像素标过去就行。
所以在真实的工程里,分割不是“看起来很高级的炫技”,而是实际决策链条里极其依赖的一环。很多对精度要求不那么夸张的量产方案,会用分割结果做可行驶区域约束;复杂方案里,分割也常作为主干特征之一,喂给后续的融合预测模块。
1.2 Unet架构的核心优势:在自动驾驶场景下的精确平衡
Unet最早是医学图像领域提出的,结构上就是一个“先压缩再还原”的编码器-解码器网络。箭头式的结构让它既能抓住“这是什么”,又能保留“这东西在哪、边界什么样”。放到自动驾驶场景里,这个特性正好踩中需求:
编码器部分负责语义理解:连续下采样让感受野变大,网络越来越懂“这是一片路面”而不是“这一小块是绿色”。
解码器部分负责空间恢复:通过上采样逐步把低分辨率的特征图还原回原图尺寸,输出逐像素分类结果。
跳跃连接负责细节保持:这是Unet最核心的设计。编码器下采样过程中,浅层特征保存着边缘、纹理等高频信息,如果不加跳跃连接,这些信息会在压缩中被丢弃,上采样后边界一团糊。跳跃连接直接把浅层特征拼到解码器对应层级,等于给细节恢复做了一次信息“直通”。
这三板斧加在一起,让Unet有几个实际无法拒绝的优点。第一,它对训练数据量的要求相对友好,一万张级别的数据就能训出可用的模型,而某些大模型动辄需要几十万张起步;第二,模型结构高度可改,换主干、加注意力模块、改上采样方式都非常容易,做实验、做迭代效率很高;第三,训练速度适中,显存压力可控,单卡就能跑起来,起步成本低。
所以结论很清楚:如果你的目标是快速跑通一套自动驾驶语义分割的方案,或者在一个实际的小规模数据集上验证分割效果,Unet是性价比最高的切入点。不是没有比它强的结构,但论综合代价,没有几个能打得过它。
2. 任务设计与数据准备:分割目标怎么定,数据怎么喂
2.1 类别体系设计:分几类、怎么分,是个工程问题
很多人一上手就急着写模型,忽略了一个更前置的问题:到底是分哪些类?这直接决定标注成本、模型容量、评估指标和后续落地可行性。
常见的设计思路是把自动驾驶场景语义分成这样几组:
- 静态结构类:道路、人行道、车道线、路沿、建筑、杆状物。
- 动态物体类:行人、车辆(再细分轿车/卡车/公交)、自行车、摩托车。
- 背景与干扰类:天空、植被、未知障碍物。
分类粒度越细,感知价值越高,但训练难度也越大。细分带来的问题是类别间相似性太高,比如“卡车”和“客车”很难只用视觉外观区分;反过来说,分类太粗又会丢失决策需要的细节,比如“道路”和“人行道”都是可行驶区域吗?显然不一样,车不能开上人行道。
一个务实的建议是:根据下游需求倒推类别。如果只是做路径规划的基础约束,那么道路、车道线、障碍物、可通行边缘这几个类别就足够;如果要做更丰富的场景理解,再逐步加细。标注资源和模型容量是有限的,不要追求“别人论文里分了30类我也分30类”,而是要问“我的决策模块到底缺什么信息”。
2.2 公开数据集与标注格式:先搞清楚Mask是什么
自动驾驶分割有一个特点:公开数据集丰富,但格式细节多。我见过很多新手栽在数据加载这一关,最常见的三个坑:
- 标注不是彩图,而是单通道索引图:很多公开数据集提供的标注,是每类对应一个数字ID的单通道灰度图,而不是给人看的彩色图。加载的时候不能直接当RGB用,要做索引映射。
- 类别ID不连续、有Ignore标记:有的数据集里ID=19是“未知区域”,评估时要忽略;有的数据集道路ID=0、人行道ID=1,但对齐到通用类别体系时要重新映射。忽略逻辑错了,mIoU计算出来是坏的。
- 原始分辨率和标注分辨率不一致:部分数据集的图像是高清的,但标注是缩小的,或者反着来。训练时要么统一resize,要么按标注尺寸裁剪原图,否则像素对齐会出问题。
所以数据准备的第一件事,不是写训练代码,而是写一个加载可视化脚本:把原图、标注图叠加画出来,确认类别映射对不对、边界对不对、有没有明显的标注错位。我每次换数据集都会先做这一步,肉眼扫一遍,比跑几十轮训练后发现指标不对再回来查高效得多。
此外,数据准备阶段还要统计各类别的像素占比。自动驾驶场景天然类别不平衡:天空、道路经常占掉一大半像素,行人和自行车只占极小比例。这个统计直接决定损失函数要不要加权重、数据增强要不要对少数类做特殊处理。我一般会用一个脚本遍历训练集,累计每个类别的像素总数,打印成表格保存下来,作为后续设计loss的基础。
2.3 数据增强管线:简单操作解决大半过拟合
自动驾驶场景的数据增强,核心目标有两个:一是提升泛化性,二是解决类别不平衡。前者的常见手段有:
- 随机水平翻转:对驾驶场景基本无损,车和行人左右对称,注意不要用垂直翻转,因为现实中车顶和路面不是上下对称的。
- 随机缩放与裁剪:固定输入分辨率下,先用随机比例缩放再裁剪固定patch,相当于同时模拟了远近变化。
- 颜色抖动:亮度、对比度、饱和度微调,模拟一天中不同光照。
- 仿射变换:轻微旋转、错切,模拟车辆行驶时的姿态变化,注意旋转超过一定角度会产生无意义区域。
后者常见的手段是类别加权采样或过采样少数类样本。如果整个数据集里行人像素占比不足2%,那模型很容易把行人学成背景。一个简单有效的做法是:统计哪些训练图包含行人、车辆等少数类,在数据加载时给这些图更高的采样概率,让它们在每个epoch里出现得更频繁。
增强参数不要太激进。旋转超过15度、缩放范围超过0.7到1.3,会让大量物体变形失真,反而伤害边界质量。实测下来,保守增强加类别加权采样,往往比激进增强加花哨loss更稳。
3. Unet模型搭建与核心环节实现
3.1 编码器:从像素到语义特征
编码器做的事,就是把HxWx3的输入图,逐级压成H/32 x W/32 x 512(假设降采样到1/32)的特征图。每一级通常包含两个卷积块(卷积+BN+ReLU),然后接一次步长为2的下采样。
一个典型的Unet编码器结构是这样的:
输入: 3x512x512 Block1: 64通道,输出 64x512x512,下采样 -> 64x256x256 Block2: 128通道,输出 128x256x256,下采样 -> 128x128x128 Block3: 256通道,输出 256x128x128,下采样 -> 256x64x64 Block4: 512通道,输出 512x64x64,下采样 -> 512x32x32 Bottleneck: 1024通道,输出 1024x32x32每个Block里的卷积,通常用3x3、padding=1,保证尺寸不变,再靠池化或卷积步长来降尺寸。通道数按64的倍数翻倍,是Unet最常见的默认设置。
在自动驾驶场景,把编码器换成预训练的残差网络(如ResNet系列)很常见,好处是能加载在ImageNet上预训练好的权重,收敛更快,泛化更好。但要注意通道数和下采样倍数的对齐,解码器得配合调整。如果纯粹想快速跑通,标准Unet编码器也完全够用。
3.2 解码器与跳跃连接:细节的恢复密码
解码器的任务是把Bottleneck的粗语义特征逐级上采样,同时每上采样一级,就把编码器对应层的输出通过跳跃连接拼过来。这个拼接是channel维度上的concat,比如解码器当前是512通道,编码器同级是256通道,concat之后变成768通道,再过卷积压回目标通道。
上采样方式有几种选择:
- 转置卷积:可以学习上采样参数,但容易产生棋盘伪影,如果kernel size不是偶数、stride配合不对,输出上会出现方格状纹理,边界质量受影响。
- 双线性插值+卷积:先插值放大,再用卷积细化,虽然多一步操作,但伪影少得多,训练更稳定。这是我在自动驾驶分割项目里的主力选择。
解码器结尾部分,通常上采样回原图分辨率,然后过一个1x1卷积,把通道数映射到类别数,再接softmax或log softmax,得到每个像素的分类概率。
这里有个特别的细节:最终输出的分辨率到底要不要等于原图分辨率。如果网络是5次下采样再到5次上采样,那最后的预测图尺寸是可以严格回到512x512的。实际工程中,为了平衡显存和速度,也常把输出固定在H/2或H/4尺度,训练时把标注等比例缩小到对应尺寸再算loss,推理时再插值回原图。这个取舍我会在后文“实时性优化”里再展开。
3.3 损失函数设计的工程选择
分割任务里最常见的损失函数是三件套:交叉熵、Dice Loss、Focal Loss。它们解决的问题完全不同。
交叉熵是默认起点,它对每个像素独立计算分类损失,梯度稳定,实现简单。但它的致命缺点是类别不平衡时,数量多的类主导梯度,少数类学不好。
Dice Loss是直接优化分割重叠度。它不关心像素总数,只关心预测区域和真实区域的重叠率,所以对小目标、稀疏类别特别友好。缺点是梯度曲率不稳定,单独用它训练容易抖动。
Focal Loss本质是交叉熵的加权版本,通过降低易分类样本的权重,把训练重心偏向难分类样本。它适合类别极度不平衡又存在“易分背景”的场景。
我的实操建议是:交叉熵 + Dice Loss加权的组合,各占一半或者按0.7/0.3配比。这个组合基本不会出大问题,Dice负责把小目标拉起来,交叉熵负责稳定收敛。类别权重的交叉熵也可以替代Focal,效果接近,但Focal多一个超参数gamma要调,工程上能用简单方案就不加复杂度。
3.4 训练参数与评估指标的选择
语义分割的核心评估指标是mIoU——所有类别的IoU取平均。IoU的计算是:预测正确的像素数除以预测区域与真实区域的并集像素数。每个类单独算IoU,再平均。
训练阶段的关键参数,我给一个保守但好用的基准值:
输入分辨率: 512x512 Batch size: 8(视显存调整) 优化器: AdamW,初始学习率3e-4 学习率策略: poly衰减(一次训练周期内从初始值平滑降到接近0) 训练轮数: 60-100个epoch(视数据量) 权重衰减: 1e-4poly衰减是分割任务里非常经典的做法,公式是 lr = base_lr * (1 - iter / total_iter)^power,power通常取0.9。它比固定学习率或者step衰减更容易磨到收敛,尤其适合语义分割这种每个像素都要参与学习的任务。
评估时除了mIoU,我还会额外看每个类别的IoU明细。只看平均指标容易掩盖问题——比如某个类别IoU很低,但平均涨上来是因为背景类学得很准。所以训练日志里一定要打印每个类的IoU表格,哪怕mIoU不涨,也要知道是哪几类在拖后腿。
4. 训练与优化的实操流程:从跑通到调好
4.1 全流程训练脚本的结构设计
一个能持续迭代的Unet训练工程,脚本结构建议分成两层:不经常变的模型定义部分和经常变的训练配置部分。前者放网络结构、损失函数、评估函数,后者用配置对象或命令行参数控制数据路径、类别数、输入尺寸、学习率、训练轮数。这样换数据集、调超参数都只改配置,不需要动模型代码。
整个训练流程的伪代码逻辑:
1. 读取配置文件:数据路径、类别数、输入尺寸、学习率、训练轮数 2. 加载训练集和验证集的数据加载器 3. 初始化Unet模型、损失函数、优化器、学习率调度器 4. 循环每个epoch: a. 遍历训练集,前向计算、算loss、反向传播、更新参数 b. 每隔N个iter,打印当前loss和单张可视化结果 c. 遍历验证集,计算mIoU和每个类别的IoU d. 如果mIoU是历史最好,保存模型权重 5. 训练结束后,在测试集或验证集上输出最终评估报告这里最容易被忽略的一步是验证集可视化。我强烈建议每个epoch之后,随机挑3-5张验证图,把原图、预测图、标注图拼成一张图保存。指标会骗人,可视化不会。有时mIoU看起来不错,但你直接看预测图会发现车道线被吃掉了、远处的小车糊成一团,这些正是指标没有完全暴露的问题。
4.2 训练过程监控:loss曲线怎么读
训练过程中,最需要盯的四个东西是:训练loss、验证loss、mIoU、单类IoU明细。
正常的训练状态是这样:训练loss稳定下降,验证mIoU同步上升;到后期训练loss继续下降但验证mIoU走平,这是开始过拟合的信号。如果训练loss降不下去,优先检查学习率是不是太高导致震荡,或者数据加载是不是有问题。如果训练loss降了但验证mIoU不涨,那多半是过拟合或者验证集数据分布和训练集差异过大。
还有一个常见误区:只盯总loss。很多分割项目的总loss包含两三项,交叉熵和Dice加一起,总数值怎么看都别扭。正确的做法是在日志里把每一项的loss分开打印,分别观察。比如交叉熵贡献稳定下降,Dice却在锯齿状抖动,那就说明Dice权重太大或学习率需要调整。
4.3 推理与后处理:从预测张量到可视化结果
模型推理的流程比训练简单,但细节不少。原始输出是一个N x C x H x W的向量,N是batch,C是类别数。要做的事情是:
# 预测张量形状: (N, C, H, W) # 第一步:取类别维度上的最大值索引 pred_index = output.argmax(dim=1) # (N, H, W),每个像素一个类别ID # 第二步:把类别ID映射成可视化颜色 pred_color = class_color_map[pred_index] # (N, H, W, 3),按颜色表替换 # 第三步:如果是缩放的输出,插值回原图尺寸再做边界对齐这里有两个容易踩的坑。一是argmax之后,要知道类别ID 0代表什么。很多数据集里0是背景,但在某些映射方案里0可能是道路。所以可视化脚本里要有颜色表,并且颜色表要和数据集标注工具配色一致,不然你和别人讨论“哪儿预测错了”的时候,说的不是同一张图。
二是推理阶段的缩放策略。训练时固定512x512,推理时有些真实输入是1920x1080,直接resize到512会导致小目标丢失。比较好的做法是滑窗推理或按比例缩放+切割推理:把大图切成若干块,每块分别预测再拼回原尺寸。这样把资源花在每一个局部细节上,小的行人、远处车道线都能保住。代价是推理时间线性增加,所以落地时要根据帧率要求做取舍。
4.4 模型加速与轻量化:让Unet跑起来而不是看起来能跑
只是训练出一个高精度模型,离“自动驾驶能用”还差得远。车载平台的算力有限,帧率要求又很高,所以部署阶段的核心矛盾是:精度和速度的取舍。
几条实用路线:
- 下采样倍数调整:如果精度允许,让模型输出H/4或者H/8分辨率的预测,再插值回原图。模型尾部减少几层上采样,推理速度立竿见影。
- 通道剪枝与轻量化主干:把编码器的64/128/256/512改成32/64/128/256,显存和计算量可以减半,精度下降通常不超过2-3个点。小模型是自动驾驶分割落地的常态,大模型很难直接上车。
- 半精度推理:用FP16替代FP32,推理速度接近翻倍,显存占用减半。如果末层有数值稳定性问题,可以只在部分层开启半精度。
- 共享特征的多任务模型:同一套编码器特征,同时输出分割、检测、车道线等多个任务,把多个模型合并成一个,整体计算量远低于单独跑多个模型。
4.5 实时性优化中容易忽略的底层约束
上面这些优化都是在“算法层”做文章,但工程上还有三个容易被忽视的底层约束:
- 主机端数据加载:GPU推理再快,如果数据从磁盘读取、做resize的瓶颈在CPU端,帧率就会被拖死。自动驾驶场景的常见做法是预加载到内存、蒸馏成小尺寸、或者直接用硬件解码流水线。
- CUDA内存峰值:大分辨率输入时,中间特征图占用非常大。有些模型看似算力跑得动,但显存不够,反而因为内存反复分配导致实际时间变慢。
- 张量布局:推理时如果图像是连续帧,尽量复用输出缓冲区,避免每帧都重新分配和回收大块内存。这个细节在嵌入式开发板上特别明显,有时能差出20%的耗时。
5. 常见问题与排查经验:我踩过的坑,替你标好路标
5.1 损失在下降,mIoU却停滞不前
这是我被问最多的问题。先确认几件事:
- 验证集和训练集的数据分布是否一致?如果验证集多是从不同视角或不同天气收集的,与训练集存在明显的域差异,指标停滞是正常的,要按场景数据混合比例重新划分验证集。
- 是不是只看了平均指标?打印出单类IoU,看是不是背景和道路涨得很高,但行人、自行车这些少数类一点没动。如果是,优先在loss里加类别权重,或者数据加载时做少数类过采样。
- 是不是一直用同一个模型权重验证,而没有把最优模型保存?这个坑很普遍:训练结束验证用的是最后一轮权重,可能过拟合了。一定要按验证mIoU保存最优权重。
5.2 小目标几乎全丢,边界一团模糊
行人和远处车辆在Unet里容易“消失”,原因在于大分辨率压缩到小特征图时,细长结构和小的区域细节完全丢了。
修这个问题的顺序是:
- 先尝试提高输入分辨率,比如从512x512改到768x768,小目标召回率往往显著提升,代价是显存和训练时间增加。
- 再换输出分辨率不变的上采样方式,确保解码器每一级都用上跳跃连接的浅层特征。
- 最后检查损失是否过度偏向大物体。如果只用交叉熵,背景占大头,少数类贡献的梯度太小。加Dice Loss或Focal Loss,往往能即刻看到少数类IoU上升。
边界模糊的问题,优先检查标签质量——很多公开数据集的标注本身在边界处就不齐,如果原标签就是“糊”的,模型学不到清晰边界,做再强的后处理都没用。
5.3 训练过程持续震荡,loss不收敛
典型症状是loss曲线像锯条一样上下大幅跳动。常见原因有三个:
- 学习率太高,分割任务的梯度噪声本来就大,低配模型用1e-3的Adam很容易震荡。降到3e-4或1e-4试试。
- batch太小,单卡显存有限时batch=2甚至batch=1,BN统计量不稳定,loss自然振荡。此时可以尝试关掉BN或换成GroupNorm。
- 数据里有错误标注。loss震荡有时不是优化问题,而是一批切好的数据里混进了标注严重错误的样本。写个可视化脚本,把loss高的样本画出来,一眼就能发现问题。
5.4 训练集表现好,验证集差很多:过拟合排查
Unet从小数据集训练非常容易过拟合。核心防线是:
- 强化数据增强。优先加颜色抖动和随机裁剪,模拟环境变化,这是对自动驾驶场景最有效的正则手段。
- 增加权重衰减,AdamW的权重衰减能显著抑制大权重,把1e-4改成1e-3试试。
- 引入Dropout或DropBlock。Unet大量使用卷积层,普通Dropout效果一般,可以在解码器的高层特征上加空间Dropout,让网络不能只依赖少数强特征。
如果上述方法都试过,还严重过拟合,那就是根本数据不够。此时与其勉强调正则,不如主动找数据:白天/夜间/雨天的变体最难收集,但也是提升泛化最关键的样本。
5.5 推理时出现棋盘状伪影
这个问题基本都出在转置卷积上。解决方案很简单:
- 把转置卷积替换成“双线性插值+普通卷积”。
- 如果一定要用转置卷积,保证kernel size能被stride整除,比如stride=2时用kernel=4,别用kernel=3或kernel=5。
棋盘伪影在分割输出上比检测严重得多,因为它会直接影响边界像素的分类,让预测图看起来像隔着一层纱。
5.6 语义混淆:相邻类别互相串
比如“人行道”被预测成“道路”,“卡车”被预测成“公交车”。这种问题结构上很难完全规避,但三个手段能明显缓解:
- 增加相似类的训练样本。如果问题类别少样本,标注或扩样优先补这些类。
- 在loss里给易混类别对的错误加额外惩罚。这做起来比较复杂,但效果直接。
- 增大输入分辨率,让网络看到更多细节纹理,减少单纯靠颜色或形状猜类别的概率。
6. 从项目到产品的三个关键提醒
Unet项目往往是从“训练一个模型”开始的,但要真正应对自动驾驶场景,有三件事建议早做规划。
第一,数据版本管理要建立。数据集会持续更新,不同版本的数据跑出来的模型效果不一致,如果连哪个模型对应哪一版数据都说不清,复盘无从谈起。我通常给数据集按日期打版,模型命名里带“数据版本-训练时间-超参关键值”,日志和权重一定是配套保存的。
第二,模型评估要场景化。自动驾驶数据往往在不同时段、天气、路段下的难度差异很大。单一的mIoU不够,建议按场景切片单独评估:晴天白天、雨天、夜间、强光逆光、隧道等。哪怕总指标一样,场景切片表现能准确暴露模型在哪类工况上撑不住。
第三,尽早做端到端延时测试。别等到模型训练完才想部署。在项目启动的第一周就搭一个最小推理管线,从“图像采集-传模型-推理-回流结果”完整跑一遍,测量每一环耗时。很多项目死在最后一步:模型精度没问题,但整套流程帧率达不到要求,前面所有努力推倒重来。
Unet在自动驾驶分割里的生态位,一句话总结:它是那个让你用最小代价跑通全链路的选择。你不会永远停留在Unet,但你会永远记得从它这里构建起来的对数据、训练、评估和部署的整体理解。如果看完这篇文章你准备动手,我最后再留一个真心建议:不要急着追求毕业级精度,先跑通一个“从原始数据到可视化预测分割图”的最小系统,确认每一个环节的输入输出对齐,再去优化指标。这一步走稳了,后面所有调优都只是时间问题。