ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

UNet车道线分割实战:TuSimple数据集从训练到部署的避坑指南

2026/10/6 3:14:35 拓冰建站 浏览量
UNet车道线分割实战:TuSimple数据集从训练到部署的避坑指南 简介这份资源面向自动驾驶车道线检测方向的初学者与进阶开发者提供一套基于U-Net模型在TuSimple数据集上完成训练与预测的完整工程。U-Net通过收缩路径捕捉上下文、扩展路径精确定位目标配合跳跃连接兼顾全局与局部信息适合车道线这类边界清晰的小目标分割任务。压缩包共15个文件约7.89MB包含7个Python脚本、2个txt说明、2个avi与2个mp4演示视频及2个md文档脚本覆盖模型定义、数据处理、训练与视频推理等环节视频直观展示实线、虚线及路面有水等场景的预测效果。目前已有583人学习下载。读者可据此掌握数据预处理、损失函数与优化器选择、训练验证及IoU、Precision、Recall等指标评估的完整流程并参考数据增强、模型融合与超参数调优等优化思路快速复现并改进车道线检测方案。1. 从一张高速路照片说起UNet 在 TuSimple 上做车道线分割到底难在哪很多人第一次跑车道线分割都会觉得这是个二分类语义分割任务UNet 结构又足够经典把 TuSimple 数据集喂进去跑几十个 epoch 就能出结果。但真正上手之后你会发现模型在直道上画出来的线又粗又准一进弯道、一遇虚线、一到强光反射路面预测出来的 mask 就开始断裂、粘连甚至整条消失。这不是玄学而是车道线这个任务本身的特殊性决定的车道线在整张图里占比极低属于典型的小目标加细长结构普通交叉熵损失会被背景像素彻底淹没模型学到的往往只是「哪里大概有条线」而不是「这条线的中心在哪、走向如何」。TuSimple 数据集的价值就在这里。它来自真实高速公路场景标注的是车道线的点序列而不是像素级 mask官方给的 label 是每根车道线若干等间距采样点的横坐标纵坐标固定。这意味着你要先做一步转换把点序列渲染成可供 UNet 训练的 mask 或者带权重的热力图这一步的处理方式直接决定后面模型能不能收敛。UNet 的编码器-解码器加跳跃连接结构天生适合这种需要同时保留语义和空间细节的任务跳跃连接把浅层的高分辨率特征直接送到解码端细线不至于在多次下采样后彻底丢失。这套方案适合谁如果你在做自动驾驶感知、车道偏离预警、或者只是想找一个结构清晰的分割项目练手UNet 加 TuSimple 是一条性价比很高的路径。它不需要多卡环境单张消费级显卡就能跑通数据集体量适中标注格式虽然需要转换但逻辑不复杂。接下来我会把从数据转换、模型搭建、训练调参到推理后处理的完整链路拆开讲重点放在那些真正会让结果翻车的地方。2. TuSimple 标注格式转换与 UNet 输入构造2.1 先搞清楚 TuSimple 的 label 到底长什么样TuSimple 的训练集包含约 3600 段视频片段每段取一帧作为训练样本总共六千多张图。标注文件是 JSON 格式每张图对应一个 lanes 列表每个 lane 里有若干采样点格式是[x, y]其中 y 是固定的几个行坐标x 是该行上车道线的横坐标如果某行没有点则用 -2 填充。这个格式和常见的像素级分割标注完全不同你不能直接拿它当 mask 用。常见做法是先把点序列插值成连续曲线再渲染成二值 mask。插值用三次样条或者简单的线性插值都行线性插值在直道上够用弯道多的话建议用样条。渲染时线宽要控制好太细了正样本太少太粗了相邻车道线容易粘连。我一般会把线宽设在 8 到 12 像素之间具体看输入分辨率。import json import cv2 import numpy as np from scipy.interpolate import splprep, splev def parse_tusimple_label(json_path, img_height720, img_width1280, line_width10): 把 TuSimple 的点序列标注转成二值 mask with open(json_path, r) as f: data json.load(f) mask np.zeros((img_height, img_width), dtypenp.uint8) for lane in data[lanes]: # 过滤掉 -2 的无效点 valid [(x, y) for x, y in zip(lane, data[h_samples]) if x ! -2] if len(valid) 2: continue xs np.array([p[0] for p in valid], dtypenp.float32) ys np.array([p[1] for p in valid], dtypenp.float32) # 用样条插值生成密集点弯道更平滑 if len(valid) 4: tck, u splprep([xs, ys], s0, kmin(3, len(valid)-1)) u_new np.linspace(0, 1, 200) x_new, y_new splev(u_new, tck) else: x_new, y_new xs, ys pts np.stack([x_new, y_new], axis1).astype(np.int32) cv2.polylines(mask, [pts], isClosedFalse, color1, thicknessline_width) return mask这段代码的核心逻辑是先剔除无效点再用样条插值把稀疏的采样点变成密集曲线最后用cv2.polylines画到 mask 上。line_width这个参数很关键它决定了正样本的像素占比。如果你的输入分辨率降到 368×640线宽也要相应缩小到 5 左右否则相邻车道线会糊在一起。splprep里的s参数控制平滑程度设 0 表示严格过点如果标注有噪声可以适当调大。2.2 输入分辨率与正负样本比例的取舍TuSimple 原图是 1280×720直接送进 UNet 显存吃不消而且车道线任务不需要那么高的分辨率。常见做法是缩放到 368×640 或者 256×512。缩放比例直接影响细线的可见度缩得太狠虚线车道线的间隙就没了模型会把它当成实线学。另一个必须处理的问题是正负样本极度不平衡。车道线像素通常只占全图的 2% 到 5%如果直接用二元交叉熵模型很快学会全预测背景就能拿到 95% 以上的准确率但 IoU 惨不忍睹。解决办法有两个方向一是用带权重的交叉熵给正样本更高的权重二是换 Dice Loss 或者 Tversky Loss这类基于重叠度的损失对不平衡数据更鲁棒。我一般会组合使用BCE 加 Dice权重各占一半。import torch import torch.nn as nn class BCEDiceLoss(nn.Module): def __init__(self, bce_weight0.5, pos_weight5.0): super().__init__() self.bce_weight bce_weight self.pos_weight torch.tensor([pos_weight]) def forward(self, pred, target): # pred 是 logitstarget 是 0/1 mask bce nn.functional.binary_cross_entropy_with_logits( pred, target, pos_weightself.pos_weight.to(pred.device) ) # Dice Loss pred_prob torch.sigmoid(pred) intersection (pred_prob * target).sum() dice 1 - (2 * intersection 1e-6) / (pred_prob.sum() target.sum() 1e-6) return self.bce_weight * bce (1 - self.bce_weight) * dicepos_weight设 5 是个经验值如果你的线宽调细了正样本更少可以加到 8 甚至 10。Dice 部分的分母加了平滑项防止除零。这个组合损失在训练初期收敛比纯 BCE 快很多而且最终 IoU 通常能高 5 到 10 个点。3. UNet 结构选型与车道线分割的适配改造3.1 原始 UNet 直接拿来用会碰到什么问题原始 UNet 是为医学细胞分割设计的输入 572×572输出 388×388用的是 valid padding导致输出比输入小一圈。放到车道线任务上这个设计有两个不合适的地方一是输出尺寸不对齐做像素级损失时还得裁剪二是编码器下采样四次最深特征图只有原图的 1/16对于细长的车道线来说深层特征的感受野太大反而丢失了线的位置精度。我一般会把 UNet 改成 same padding保证输入输出尺寸一致然后把下采样次数控制在 3 到 4 次之间。下采样 3 次的话最深特征图是原图的 1/8对于 368×640 的输入来说就是 46×80这个尺度上车道线的结构信息还保留得比较好。下采样 4 次到 1/16感受野更大适合捕捉车道线的全局走向但需要配合更强的跳跃连接来恢复细节。编码器主干可以用原始 UNet 的卷积堆叠也可以换成预训练的 ResNet 或者 MobileNet。用预训练权重的好处是收敛快尤其是数据量不够大的时候。但要注意ImageNet 预训练模型的第一层是处理 RGB 自然图像的车道线场景和自然图像分布有差异微调时学习率要设小一点我一般用 1e-4 起步主干部分用 1e-5。3.2 跳跃连接上加注意力让模型聚焦在车道线区域原始 UNet 的跳跃连接是直接 concat把编码器浅层特征和解码器深层特征拼在一起。但浅层特征里包含大量背景纹理比如路面标线、阴影、车辆边缘这些噪声会干扰解码器。一个有效的改进是在跳跃连接上加注意力门控让模型自己学习哪些空间位置值得关注。class AttentionBlock(nn.Module): 注意力门控用解码器特征指导编码器特征的加权 def __init__(self, F_g, F_l, F_int): super().__init__() self.W_g nn.Sequential( nn.Conv2d(F_g, F_int, 1, biasFalse), nn.BatchNorm2d(F_int) ) self.W_x nn.Sequential( nn.Conv2d(F_l, F_int, 1, biasFalse), nn.BatchNorm2d(F_int) ) self.psi nn.Sequential( nn.Conv2d(F_int, 1, 1, biasFalse), nn.BatchNorm2d(1), nn.Sigmoid() ) self.relu nn.ReLU(inplaceTrue) def forward(self, g, x): # g 是解码器上采样后的特征x 是编码器跳跃连接的特征 g1 self.W_g(g) x1 self.W_x(x) psi self.relu(g1 x1) psi self.psi(psi) return x * psiF_g是解码器特征的通道数F_l是编码器特征的通道数F_int是中间层通道数一般取F_l // 2。这个模块的计算量很小但效果在车道线任务上很直观加了注意力之后模型在弯道和虚线处的预测连续性明显变好因为背景噪声被抑制了。注意psi最后过 Sigmoid 得到 0 到 1 的权重图直接乘在编码器特征上不改变通道数所以后续 concat 的逻辑不用动。3.3 输出层用多尺度监督加速收敛车道线分割的另一个痛点是训练初期梯度稀疏因为正样本太少损失回传的信号很弱。一个实用的技巧是在解码器的不同尺度上都加输出头做深监督。比如在 1/4、1/8、1/16 三个尺度上分别接一个 1×1 卷积输出预测然后把这些预测上采样到原图尺寸分别算损失再加权求和。这样做的好处是浅层的输出头能直接收到梯度编码器浅层学到的边缘特征更快被利用起来。权重分配上我一般给最深尺度的输出头 0.5中间 0.3最浅 0.2。训练稳定之后可以把浅层输出头去掉只保留最终输出推理时速度更快。4. 训练配置、学习率策略与显存优化4.1 优化器选择和初始学习率怎么定车道线分割任务上Adam 和 SGD 都能用但行为差别不小。Adam 收敛快前几个 epoch 损失就降得很明显但最终精度有时不如调好的 SGD。SGD 加动量需要更长的训练时间但对超参没那么敏感泛化性通常更好。我一般先用 Adam 跑一版 baseline确认数据和模型没问题再换 SGD 精调。初始学习率方面如果编码器用了预训练权重整体学习率设 1e-3 到 1e-4 之间主干部分可以再降一个数量级。如果从头训练1e-3 起步比较稳。学习率调度用余弦退火或者 ReduceLROnPlateau 都行前者需要预设总 epoch 数后者根据验证集指标自动降。我倾向余弦退火配合 warmup前 500 步线性从 0 升到初始学习率避免训练初期梯度爆炸。from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR model UNetWithAttention(in_channels3, out_channels1).cuda() optimizer Adam([ {params: model.encoder.parameters(), lr: 1e-4}, {params: model.decoder.parameters(), lr: 1e-3} ], weight_decay1e-5) warmup LinearLR(optimizer, start_factor0.1, total_iters500) cosine CosineAnnealingLR(optimizer, T_max50, eta_min1e-6) scheduler SequentialLR(optimizer, schedulers[warmup, cosine], milestones[500])这里把编码器和解码器的学习率分开设是因为预训练编码器已经学到了通用特征不需要大改而解码器是随机初始化的需要更快的学习速度。weight_decay设 1e-5 防止过拟合如果数据集小可以加到 1e-4。4.2 batch size 太小怎么办梯度累积与混合精度车道线分割的输入分辨率不低UNet 参数量也不小单卡 8G 显存可能只能塞下 batch size 2 或者 4。batch size 太小会导致 BatchNorm 统计量不准训练不稳定。解决办法有两个一是把 BatchNorm 换成 GroupNorm不受 batch size 影响二是用梯度累积攒几个 batch 的梯度再更新一次。混合精度训练是另一个省显存的手段用torch.cuda.amp把前向和反向计算转成 float16显存占用能降 30% 到 40%速度也有提升。但要注意损失计算和 BatchNorm 部分最好保持在 float32避免数值不稳定。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() accum_steps 4 for epoch in range(total_epochs): for i, (imgs, masks) in enumerate(train_loader): imgs, masks imgs.cuda(), masks.cuda().float() with autocast(): preds model(imgs) loss criterion(preds, masks) / accum_steps scaler.scale(loss).backward() if (i 1) % accum_steps 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad() scheduler.step()accum_steps设 4 意味着等效 batch size 翻四倍。注意损失要除以accum_steps否则梯度会累积成四倍。GradScaler自动处理 float16 的梯度缩放防止下溢。这套组合下来8G 显存跑 368×640 输入、batch size 等效 16 是没问题的。4.3 验证集指标怎么选IoU 不够还要看连续性车道线分割不能只看 IoU。IoU 高不代表车道线连续可能模型预测了一大片粗线和标注的细线重叠度还行但实际用的时候根本没法拟合出准确的车道线方程。我一般会同时看三个指标像素级 IoU、车道线实例的检测率、以及拟合后的横向误差。实例检测率的算法是对预测 mask 做连通域分析每个连通域拟合一条曲线看这条曲线和标注曲线的距离是否在阈值内。横向误差则是把预测的车道线在几个固定行上取横坐标和标注的横坐标做差取平均绝对值。这两个指标更贴近实际使用效果。验证频率不用太高每 5 个 epoch 跑一次就行因为验证集推理比较耗时。保存模型时以验证集的综合指标为准不要只看训练损失。5. 推理后处理与常见翻车现场排查5.1 从 mask 到车道线方程后处理链路UNet 输出的是概率图经过阈值化得到二值 mask但 mask 本身不是最终产物你需要的是车道线的参数方程或者点序列。后处理的第一步是连通域分析把 mask 里分离的车道线实例分开。这里有个坑如果两条车道线在远处相交或者靠得很近连通域会粘在一起需要用形态学操作或者基于曲线拟合的方法拆开。常见做法是对每个连通域做骨架化提取中心线然后用 RANSAC 或者最小二乘拟合三次曲线。拟合时只取 mask 内像素的坐标按 y 坐标排序对 x 做多项式拟合。三次曲线对高速公路场景够用弯道急的话可以升到四次。import numpy as np from sklearn.preprocessing import PolynomialFeatures from sklearn.linear_model import RANSACRegressor from sklearn.pipeline import make_pipeline def mask_to_lane_curve(mask, degree3): 从单条车道线的 mask 拟合曲线 ys, xs np.where(mask 0) if len(xs) 20: return None # 按 y 排序每个 y 取 x 的中位数减少线宽影响 unique_ys np.unique(ys) median_xs [np.median(xs[ys y]) for y in unique_ys] X np.array(unique_ys).reshape(-1, 1) y np.array(median_xs) model make_pipeline( PolynomialFeatures(degreedegree), RANSACRegressor(min_samples0.5, residual_threshold5.0) ) model.fit(X, y) return modelresidual_threshold设 5 像素意思是拟合时允许 5 个像素以内的偏差超过的算外点。这个值根据你的线宽和标注精度调线宽 10 的话设 5 到 8 比较合适。RANSAC 的好处是能排除 mask 边缘的噪声点拟合出的曲线更接近车道线中心。5.2 避坑五个让车道线预测翻车的典型问题现象一训练损失降得很快但验证集 IoU 一直上不去。原因通常是正负样本极度不平衡模型学会了全预测背景。解决方法是检查损失函数有没有加正样本权重或者换 Dice Loss。另外确认一下 mask 渲染的线宽是不是太细正样本占比低于 1% 的话模型很难学到有效特征。现象二直道预测很好弯道处车道线断裂。这是感受野不够导致的。弯道处车道线的曲率变化需要更大的感受野才能捕捉到全局走向。解决办法是加深编码器或者把下采样次数从 3 次加到 4 次同时配合注意力跳跃连接保留细节。另一个可能是训练数据里弯道样本太少可以针对性做数据增强比如随机旋转和透视变换。现象三相邻车道线粘连成一片。线宽设太大了或者输入分辨率太低导致两条线在特征图上混在一起。先把线宽降到 6 到 8 像素如果还粘连检查一下标注里有没有两条线距离特别近的样本这种样本在损失里可以适当降权。后处理阶段也可以用连通域面积和长宽比来过滤粘连的连通域通常面积异常大。现象四推理时显存溢出但训练时正常。推理时没有梯度计算显存占用应该更小才对。如果溢出大概率是输入分辨率没对齐比如训练用 368×640推理时送了原图 1280×720 进去。检查预处理 pipeline确保推理和训练的 resize 逻辑一致。另外torch.no_grad()别忘了加。现象五换了预训练权重之后效果反而变差。ImageNet 预训练模型的输入归一化是 mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]如果你用自己的归一化参数分布对不上浅层特征就废了。要么统一用 ImageNet 的归一化参数要么把预训练权重的第一层冻住只训后面的层。6. 把 UNet 车道线模型推到实用精度的几个进阶技巧训练跑通只是第一步要把 IoU 从 0.6 推到 0.75 以上需要一些更细的操作。第一个技巧是难例挖掘。车道线任务里强光反射、路面磨损、夜间场景是主要的难例来源。我一般会在训练中期跑一遍验证集把 IoU 低于 0.5 的样本挑出来在后续 epoch 里给这些样本更高的采样权重。实现上可以用WeightedRandomSampler给每个样本一个权重难例权重设 2 到 3 倍。第二个技巧是测试时增强。推理时把输入做水平翻转、小角度旋转分别预测后再把结果融合。车道线在水平翻转下语义不变融合能有效降低单次预测的方差。具体做法是翻转后的预测再翻转回来和原始预测取平均然后阈值化。这个操作推理耗时翻倍但 IoU 通常能涨 1 到 2 个点。第三个技巧是后处理里的曲线平滑。RANSAC 拟合出来的曲线在远端可能抖动因为远处像素少、噪声大。可以对拟合后的曲线做一次滑动平均窗口大小取 5 到 7 个采样点。另外如果场景是高速公路可以加一个先验约束车道线的曲率变化是连续的相邻两帧的曲线参数不应该突变。如果是视频流用卡尔曼滤波跟踪曲线参数能显著提升稳定性。技巧预期收益额外成本适用阶段难例挖掘IoU 2~4训练时间 20%训练中期测试时增强IoU 1~2推理时间 ×2推理阶段曲线平滑横向误差 -15%几乎无后处理卡尔曼跟踪视频稳定性大幅提升需视频流部署阶段最后说一个我踩过的坑不要盲目堆 epoch。车道线分割任务在 50 到 80 个 epoch 之间通常就收敛了再往后训练损失还在降但验证集指标开始波动这是过拟合的信号。我一般会设一个早停机制验证集 IoU 连续 10 个 epoch 不提升就停同时保存验证集指标最好的那个 checkpoint而不是最后一个。这个习惯帮我省了很多无谓的等待时间也避免了拿过拟合模型去部署的尴尬。希望帮到你。本文还有配套的精品资源点击获取