【深度学习新手踩坑实录】车道线分割模型从“垃圾输出”到“精准识别”,我到底踩了多少坑?(附GitHub+数据集链接)

84 阅读12分钟

车道线分割作为自动驾驶中的关键任务,一直是计算机视觉领域的热门研究方向。本文将详细介绍一个基于UNet++架构的多标签车道线分割项目,重点分享新手在开发过程中常见的技术坑及其解决方案,帮助初学者快速上手并避免常见错误。

摘要 :本文记录了基于UNet++的多标签车道线分割项目开发全过程,重点复盘新手开发中易踩的技术坑及对应解决方案,兼顾实用性和可读性。项目使用的数据集来自百度AI Studio的car_data_2022,适配0-3四类标签的多标签分割场景。完整可运行代码已上传至GitHub,新手可直接参考避坑、快速上手运行项目。

关键词:UNet++;多标签车道线分割;新手避坑;PyTorch;深度学习分割;实战

微信聊天框.jpg 今天就和大家分享,我是如何逐步排查问题,将效果不佳的模型,优化成能精准识别多标签车道线的可用版本,全程干货+真实踩坑经验,新手宝子直接抄作业,别再走我的弯路啦!

一、先交代背景:我要做一个“能分清车道线”的模型

初衷很简单:实现车道线分割,用于自动驾驶辅助(主打一个新手练手,不敢真上车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三种车道线分别配了红、绿、蓝三种颜色,背景透明,叠加在原图上,效果一目了然:

multi_label_seg_result.png

四、新手避坑指南(含泪总结)

做车道线分割这几天,我踩的坑能绕地球半圈,总结几条新手必看的避坑指南,帮你节省debug时间,少掉几根头发:

  • 先看数据集标签!先看数据集标签!先看数据集标签!别像我一样,连多标签和多分类都搞混,硬跑20轮,纯纯浪费时间;
  • 数据增强要适度,尤其是几何变换(如旋转、错切),车道线是有强几何约束的,乱转会破坏这种约束,反而让模型学不会;
  • Loss函数别只用BCE,车道线像素占比太小了,必须加上Dice Loss或者IoU Loss,强制模型关注“形状重合度”,治好车道线断断续续的毛病;
  • 遇到报错别慌,先打印tensor的shape,90%的bug都是维度不匹配搞出来的(特别是做One-Hot编码和Loss计算的时候)。

如果你们也在做车道线分割,遇到了和我一样的问题,欢迎在评论区交流,咱们一起避坑,一起进步~希望这份避坑指南能帮新手宝子们少走弯路!如果觉得有帮助,别忘了点个赞或者去GitHub给个Star哦~代码仓库见文首!

📌 下期预告

本期我们完成了CV实战,下期我们将开启NLP实战,带大家做一个基于word2vec、LSTM的恶意评论检测模型。依旧延续新手友好、避坑指南的风格,带你快速上手NLP项目开发。我们下期不见不散~