车道线分割作为自动驾驶中的关键任务,一直是计算机视觉领域的热门研究方向。本文将详细介绍一个基于UNet++架构的多标签车道线分割项目,重点分享新手在开发过程中常见的技术坑及其解决方案,帮助初学者快速上手并避免常见错误。
摘要 :本文记录了基于UNet++的多标签车道线分割项目开发全过程,重点复盘新手开发中易踩的技术坑及对应解决方案,兼顾实用性和可读性。项目使用的数据集来自百度AI Studio的car_data_2022,适配0-3四类标签的多标签分割场景。完整可运行代码已上传至GitHub,新手可直接参考避坑、快速上手运行项目。
关键词:UNet++;多标签车道线分割;新手避坑;PyTorch;深度学习分割;实战
今天就和大家分享,我是如何逐步排查问题,将效果不佳的模型,优化成能精准识别多标签车道线的可用版本,全程干货+真实踩坑经验,新手宝子直接抄作业,别再走我的弯路啦!
一、先交代背景:我要做一个“能分清车道线”的模型
初衷很简单:实现车道线分割,用于自动驾驶辅助(主打一个新手练手,不敢真上车hhh)。核心需求是:能识别出图片中的3种不同车道线,而且这3种车道线能同时存在。
1. 数据集揭秘:原来我一直看错了“标签”
最开始我拿到数据集,随便打开一张掩码图,看到只有0、1、2、3四种像素,心想:这不就是个二分类任务吗?0是背景,剩下的是车道线,简单!
直到我跑了20轮训练,分割结果全是噪声,甚至连一条完整的车道线都看不到,我才急了——翻出数据集里的labels.txt一看,当场石化:好家伙!居然有0、1、2、3四种标签,0是背景,1、2、3分别是三种不同的车道线,而且这三种车道线能同时存在(也就是多标签任务),不是我以为的“只能选一个”的多分类!
举个通俗的例子:多分类就像你只能选一种奶茶,多标签就像你可以选奶茶+珍珠+椰果,三种车道线就是“珍珠、椰果、芋圆”,可以同时出现在一张图里,而我之前硬按“只能选一种”来训练,模型能学好才怪!
2. 模型选型:新手友好的UNet++
既然是新手做车道线分割,选模型肯定要“新手友好+效果能打”,对比了UNet、FCN、HRNet之后,我果断选了UNet++——原因很简单:
- 结构不算复杂,新手能看懂,而且容易修改;
- 比基础UNet多了skip connection,能更好地保留车道线的细粒度特征(毕竟车道线是细长的,细节很重要);
- 训练速度不算慢,我用GPU跑,12800个样本,30轮也只花了半天。
这里插一句:新手做分割,别上来就选HRNet这种复杂模型,UNet++足够应付大部分入门场景,等把基础踩稳了,再升级模型也不迟~
二、大型踩坑现场:那些让我debug到秃头的错误
如果说“把多标签当多分类”是最大的坑,那后续的debug,就是“坑中坑中坑”,每一个错误都让我怀疑自己的代码能力,话不多说,上干货(避坑指南)!
坑1:二分类→多标签,模型“偷懒”不预测车道线
最开始我把所有非0标签(1、2、3)都转成了1,当成二分类任务训练,损失曲线看着很“健康”(从0.8降到0.3),但分割结果却惨不忍睹——全是背景,偶尔有几个噪声点,就像模型在“摆烂”:反正预测背景损失低,我干嘛费劲预测车道线?
原因很简单:车道线在图片中占比不到5%,背景占95%,二分类的损失函数(BCE)会让模型“趋利避害”,优先预测背景,毕竟错分背景的损失,比错分车道线小太多!
修正方案:把单通道掩码(0-3)改成4通道多标签掩码,每个通道对应一个类别(0=背景,1=车道1,2=车道2,3=车道3),值为0或1,代表“该类别是否存在”。
坑2:数据增强的坑:矩形图片旋转90度后的“惨案”
我想着为了让模型更鲁棒,得加点数据增强(Data Augmentation)。我直接上了一套“albumentations全家桶”,其中包含了一个看起来人畜无害的操作:RandomRotate90(随机旋转90度)。
结果代码一跑,在 DataLoader 取数据的时候直接崩了,报了一个非常经典的错误: RuntimeError: stack expects each tensor to be equal size, but got [4, 256, 512] at entry 0 and [4, 512, 256] at entry 1
🔍 发生了什么?
我的原始图片尺寸是 256 (高) x 512 (宽) ,是一个长方形。
- 当图片不旋转时:形状是
(256, 512)。 - 当图片触发旋转90度时:形状变成了
(512, 256)。
在一个 Batch里,如果恰好有的图转了,有的没转,那么它们的形状就不一致。PyTorch 的 DataLoader 试图把它们打包成一个 Tensor 时,发现宽高对不上,直接罢工。
🛑 坑点分析: 对于正方形图片(如 512x512),旋转90度后形状不变,完全没问题。但对于矩形图片,旋转90度会互换宽高。除非你在旋转后强制 Resize 回固定尺寸,否则 Batch 训练必报错。
而且,仔细一想,车道线分割适合旋转90度吗? 不适合!车道线通常是垂直或向远处汇聚的,横着的车道线(变成斑马线那样)在实际驾驶视角中几乎不存在。强行旋转90度不仅搞崩了代码,还制造了不符合物理规律的“伪数据”。
坑3:损失函数的坑:只用BCE Loss,车道线断断续续?
搞定了数据预处理和增强,终于可以开始训练了。因为这是一个多标签分割任务(即每个像素可以同时属于不同类,或者我们把多分类问题转化为了One-Hot形式的多通道二分类问题),我理所当然地选用了 PyTorch 自带的 nn.BCEWithLogitsLoss()。
训练跑得很快,Loss 下降得也挺顺滑。但是,当我把预测出的车道线画在图上时,心态崩了:车道线断断续续的,像虚线一样,而且边缘非常模糊。
🔍 到底哪里出了问题?
我百思不得其解:BCE Loss 明明下降了啊? 深入研究后发现,单独使用 BCE Loss 有个致命弱点:它只关注单个像素的分类对不对,而不关注整体形状。 对于车道线这种细长的结构,BCE Loss 觉得:“哎呀,我只要把大部分背景预测对了,车道线哪怕断了几截,总体准确率也挺高的嘛。”
它缺乏对几何结构和区域重叠度的感知能力。在车道线这种正负样本极度不平衡(背景极大,车道线极细)的场景下,BCE 很容易“摆烂”。
✅ 解决方案: 必须引入 Dice Loss。 Dice Loss 源于 Dice 系数,本质上是在衡量预测区域和真实区域的重合程度(IoU)。它不在乎背景有多大,只在乎你预测的那条线和真实的线是不是完美重叠。 但是 Dice Loss 训练有时候不稳定,所以最佳实践是 “BCE + Dice” 混合双打:
- BCE:保底,维持像素级的分类梯度。
- Dice:强攻,强制模型去拟合车道线的整体形状,解决断连问题。
三、修正后的模型详解:从“摆烂”到“靠谱”的蜕变
踩完所有坑,模型终于“走上正轨”,下面详细介绍一下修正后的核心代码,新手宝子可以直接抄作业,不用再踩我踩过的坑!
1. 数据集处理(dataset.py):多标签掩码的正确打开方式
核心是把单通道标签转成4通道多标签,并且正确处理数据增强,避免标签被破坏:
def __getitem__(self, idx):
# 读取图片和掩码路径
img_path, mask_path = self.pairs[idx]
# 读取图像
image = cv2.imread(img_path)
if image is None:
raise ValueError(f"无法读取图片:{img_path}")
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # 转成RGB格式
# 读取掩码并转成多标签格式:单通道(0-3) → 4通道[4,H,W]
mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 单通道灰度图
if mask is None:
raise ValueError(f"无法读取掩码:{mask_path}")
num_classes = 4
h, w = mask.shape
multi_label_mask = np.zeros((num_classes, h, w), dtype=np.float32)
for cls in range(num_classes):
multi_label_mask[cls] = (mask == cls).astype(np.float32)
mask = multi_label_mask # [4,H,W]
# 数据增强
if self.transform:
mask_trans = mask.transpose(1, 2, 0) # [4,H,W] → [H,W,4]适配albumentations
augmented = self.transform(image=image, mask=mask_trans)
image = augmented['image'] # Tensor格式已转置[4,H,W]
mask_aug = augmented['mask'] # [H,W,4]
mask = mask_aug.permute(2, 0, 1) # 转回[4,H,W]
return image, mask
2. 数据增强(get_transforms):
def get_transforms(img_size=(256, 512)):
train_transform = Compose([
Resize(height=img_size[0], width=img_size[1]), # 调整尺寸
HorizontalFlip(p=0.5), # 水平翻转
RandomBrightnessContrast(p=0.3), # 亮度对比度调整
Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), # 归一化
ToTensorV2() # 转成Tensor
])
val_transform = Compose([
Resize(height=img_size[0], width=img_size[1]), # 调整尺寸
Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), # 归一化
ToTensorV2() # 转成Tensor
])
return train_transform, val_transform
3. 损失函数:多标签专属,专治模型“偷懒”
多标签任务不能用CrossEntropyLoss(多分类专属),也不能用普通的BCELoss(会让模型偷懒),我用的是多标签DiceBCELoss,结合了BCE的稳定性和DiceLoss对小目标(车道线)的友好性:
class MultiLabelDiceBCELoss(nn.Module):
def __init__(self, num_classes=4, smooth=1e-6):
super().__init__()
self.num_classes = num_classes
self.smooth = smooth
self.bce_loss = nn.BCEWithLogitsLoss() # 内置sigmoid,适合多标签
def dice_loss_per_class(self, pred, target):
pred = torch.sigmoid(pred)
intersection = (pred * target).sum()
union = pred.sum() + target.sum()
return 1 - (2 * intersection + self.smooth) / (union + self.smooth)
def forward(self, inputs, targets):
# inputs: [batch,4,H,W](模型输出,未sigmoid)
# targets: [batch,4,H,W](多标签掩码,0/1)
# 多标签BCE损失
bce_loss = self.bce_loss(inputs, targets)
# 多标签Dice损失(每个类别独立计算,再平均)
dice_loss = 0.0
for cls in range(self.num_classes):
dice_loss += self.dice_loss_per_class(inputs[:, cls], targets[:, cls])
dice_loss /= self.num_classes
# 总损失
return bce_loss + dice_loss
4. 模型修改(UNet++):输出通道适配多标签
UNet++的核心结构不变,只需要把输出通道数从1(二分类)改成4(多标签,对应0-3四个类别),并且去掉softmax(多标签用sigmoid,每个类别独立预测):
class UNetPlusPlus(nn.Module):
def __init__(self, in_channels=3, num_classes=4):
super().__init__()
self.channels = [64, 128, 256, 512] # 编码器通道数
# 编码器
self.conv1 = ConvBlock(in_channels, self.channels[0])
self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2)
self.conv2 = ConvBlock(self.channels[0], self.channels[1])
self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2)
self.conv3 = ConvBlock(self.channels[1], self.channels[2])
self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2)
self.conv4 = ConvBlock(self.channels[2], self.channels[3])
# 解码器(嵌套拼接)
self.up4_3 = UpBlock(self.channels[3], self.channels[2])
self.conv4_3 = ConvBlock(self.channels[2] + self.channels[2], self.channels[2])
self.up3_2 = UpBlock(self.channels[2], self.channels[1])
self.conv3_2 = ConvBlock(self.channels[1] * 3, self.channels[1])
self.up2_1 = UpBlock(self.channels[1], self.channels[0])
self.conv2_1 = ConvBlock(self.channels[0] * 4, self.channels[0])
# 输出层
self.out = nn.Conv2d(self.channels[0], num_classes, kernel_size=1)
def forward(self, x):
# 编码器
x1 = self.conv1(x)
x1_pool = self.pool1(x1)
x2 = self.conv2(x1_pool)
x2_pool = self.pool2(x2)
x3 = self.conv3(x2_pool)
x3_pool = self.pool3(x3)
x4 = self.conv4(x3_pool)
# 解码器
x4_up = self.up4_3(x4)
x4_3 = torch.cat([x4_up, x3], dim=1)
x4_3_conv = self.conv4_3(x4_3)
x3_up = self.up3_2(x3)
x4_3_up = self.up3_2(x4_3_conv)
x3_2 = torch.cat([x3_up, x2, x4_3_up], dim=1)
x3_2_conv = self.conv3_2(x3_2)
x2_up = self.up2_1(x2)
x3_2_up = self.up2_1(x3_2_conv)
x4_3_up2 = self.up2_1(x4_3_up)
x2_1 = torch.cat([x2_up, x1, x3_2_up, x4_3_up2], dim=1)
x2_1_conv = self.conv2_1(x2_1)
# 输出层
out = self.out(x2_1_conv)
return out
5. 可视化:多颜色叠加,清晰区分不同车道线
多标签的可视化需要用不同颜色区分不同车道线,我给1、2、3三种车道线分别配了红、绿、蓝三种颜色,背景透明,叠加在原图上,效果一目了然:
四、新手避坑指南(含泪总结)
做车道线分割这几天,我踩的坑能绕地球半圈,总结几条新手必看的避坑指南,帮你节省debug时间,少掉几根头发:
- 先看数据集标签!先看数据集标签!先看数据集标签!别像我一样,连多标签和多分类都搞混,硬跑20轮,纯纯浪费时间;
- 数据增强要适度,尤其是几何变换(如旋转、错切),车道线是有强几何约束的,乱转会破坏这种约束,反而让模型学不会;
- Loss函数别只用BCE,车道线像素占比太小了,必须加上Dice Loss或者IoU Loss,强制模型关注“形状重合度”,治好车道线断断续续的毛病;
- 遇到报错别慌,先打印tensor的shape,90%的bug都是维度不匹配搞出来的(特别是做One-Hot编码和Loss计算的时候)。
如果你们也在做车道线分割,遇到了和我一样的问题,欢迎在评论区交流,咱们一起避坑,一起进步~希望这份避坑指南能帮新手宝子们少走弯路!如果觉得有帮助,别忘了点个赞或者去GitHub给个Star哦~代码仓库见文首!
📌 下期预告
本期我们完成了CV实战,下期我们将开启NLP实战,带大家做一个基于word2vec、LSTM的恶意评论检测模型。依旧延续新手友好、避坑指南的风格,带你快速上手NLP项目开发。我们下期不见不散~