
简介面向自动驾驶和计算机视觉开发者这套资源提供了使用U-Net模型在TuSimple数据集上训练车道线检测的PyTorch实现解决分割网络从数据准备到效果验证的落地问题。包内共15个文件以7个Python脚本为核心覆盖数据预处理、模型结构、训练测试和视频预测另有2份Markdown和2份TXT记录配置与说明2个AVI和2个MP4展示实线、虚线、雨天等路况的预测效果整体仅7.89MB。目前已有578人学习下载适合希望复现实验、理解图像分割训练细节的中高级开发者。借助完整代码与演示可重点学习收缩与扩展路径、跳跃连接、交叉熵损失与优化器搭配、TuSimple标签转二值图等环节也能直观评估模型在弯曲车道、遮挡和光照变化下的表现。无论是快速启动自己的分割项目还是为引入注意力机制、数据增强或模型融合提供基线参考都具备实际价值。1. 车道线检测常被想复杂UNet在Tusimple上的分割路线车道线检测常被想复杂。很多团队一上来就追端到端方案却忽略了UNet这种分割结构在Tusimple数据集上就够用。车道线是细长目标转成二值分割图后UNet的编码器提取上下文、跳跃连接恢复细节恰好匹配全局连贯、局部纤细的需求这也是unet图像分割在自动驾驶场景里最常见的落点之一。Tusimple的3626张训练图像自带JSON格式采样点标注转成mask可直接监督UNet。链路不涉及anchor设计、目标匹配环境依赖只有PyTorch加opencv8GB显存就能跑通最小实验特别适合拿来做unet训练自己的数据集的起点模板。适合跑过分类任务、想切入分割方向的工程师以及需要把UNet适配到自有车道数据的算法同学。下文按数据预处理→模型搭建→训练调优→后处理拟合展开。2. UNet结构拆解编码器、跳跃连接与车道线分割的适配点2.1 编码器下采样次数决定感受野上限UNet的编码器通常由4到5个卷积阶段组成每个阶段是两次3×3卷积加ReLU阶段之间做stride2的下采样特征图尺寸减半、通道数翻倍。这个设计对车道线任务的意义要从感受野来看一条车道线在Tusimple原始图像里只有几到十几像素宽却要跨越图像高度的一半以上模型既要看清线条边缘的局部梯度又要在远处弯道处理解线条的延伸方向。常见的错误是照搬分割比赛里512×512输入的配置直接下采样5次。这样最小特征图只有16×16靠近图像顶部、宽度仅2到3个像素的车道线信息会被稀释得很厉害。反过来如果只下采样2次感受野只有几十个像素遇到曲率稍大的弯道预测出的车道线会出现明显的锯齿和断裂。我一般固定4次下采样最后一个阶段把卷积换成膨胀系数为2的空洞卷积让感受野增大而不增加参数量。这个配置在Tusimple上比较平衡后续做unet模型改进时也方便在此基础上换模块。2.2 跳跃连接拼接的是空间细节不是特征复用跳跃连接是UNet区别于FCN这类纯编码解码结构的关键。编码器浅层特征图分辨率高但语义弱深层特征图语义强但空间位置模糊跳跃连接把两者沿通道维度拼接后交给解码器解码器每一级都同时拿到这条线应该长什么样和这条线具体在哪两类信息。实际使用时注意三点。第一拼接比逐元素相加更消耗显存通道数翻倍后Tusimple输入尺寸建议用512×256或640×360不要直接上1280×720第二每个编码器阶段只保留一个输出特征参与拼接不要在每层卷积之后都往外拉特征否则跳跃连接携带的信息与主分支重复度过高解码器的融合权重会退化第三BN层的均值和方差在小batch下不稳定batch size低于8时建议把BN换成GroupNorm收敛速度稍慢但验证集F1更稳定。这些都是unet使用时的注意事项里最容易忽略的点。2.3 输出通道数与UNet最小实现输出通道数取决于标签的组织方式。Tusimple标注最多6条车道多通道方案让每条车道各占一个通道模型直接输出类别概率二值方案把所有车道合成1个通道实例区分交给后处理。第一版建议用二值方案loss稳定评估简单后处理阶段也有更多调优空间。PyTorch下的最小实现import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_ch3, out_ch1, base64): super().__init__() self.enc1 DoubleConv(in_ch, base) self.enc2 DoubleConv(base, base * 2) self.enc3 DoubleConv(base * 2, base * 4) self.enc4 DoubleConv(base * 4, base * 8) self.pool nn.MaxPool2d(2) self.bridge DoubleConv(base * 8, base * 16) self.up4 nn.ConvTranspose2d(base * 16, base * 8, 2, stride2) self.dec4 DoubleConv(base * 16, base * 8) self.up3 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.dec3 DoubleConv(base * 8, base * 4) self.up2 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.dec2 DoubleConv(base * 4, base * 2) self.up1 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.dec1 DoubleConv(base * 2, base) self.head nn.Conv2d(base, out_ch, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) b self.bridge(self.pool(e4)) d4 torch.cat([self.up4(b), e4], dim1) d4 self.dec4(d4) d3 torch.cat([self.up3(d4), e3], dim1) d3 self.dec3(d3) d2 torch.cat([self.up2(d3), e2], dim1) d2 self.dec2(d2) d1 torch.cat([self.up1(d2), e1], dim1) d1 self.dec1(d1) return self.head(d1)base控制整个模型的宽度Tusimple场景下base32就能看到有效训练信号base64配512×256输入在8GB显存上可以跑通。卷积层设置biasFalse是因为后面紧跟BN偏置会被归一化抵消保留反而冗余padding1保证尺寸在卷积前后不变跳跃连接拼接时两侧空间天然对齐不需要额外裁剪。head用1×1卷积把通道压到输出数二值分割时out_ch1配合BCEWithLogitsLoss使用不要在模型内部提前做sigmoid否则数值稳定性变差。2.4 资源受限时的轻量化改造方向如果显存只有6GB或者需要实时推理先别动下采样次数优先改两点把DoubleConv里的标准卷积换成深度可分离卷积参数量大约降到原来的1/8Tusimple上精度损失通常不超过1个F1点或者把bridge处的连续卷积替换成ASPP模块用不同膨胀率的并行分支捕获多尺度上下文。另一种做法是把编码器换成MobileNetV2预训练主干。这里容易踩的坑是预训练权重基于ImageNet的224×224输入而Tusimple图像接近16:9直接resize到224×224会把车道压变形。建议保持640×360等比缩放加载权重后重新设计stem层的stride为2并在前几个epoch冻结BN的running stats等网络适应新分辨率再解冻。提示初期迭代别一上来就轻量化先用标准UNet跑通链路建立baseline。否则精度掉点时分不清是结构问题还是训练问题。3. Tusimple数据预处理与PyTorch训练脚本3.1 把JSON采样点转成二值maskTusimple的标注文件是JSON Lines格式每一行对应一张图结构包含lanes、h_samples和raw_file三个字段。h_samples是固定的y坐标数组lanes里每个数组是与y坐标一一对应的x坐标取值为-2表示该行没有这条车道线需要剔除。生成mask时不能直接拿这些点画多边形正确做法是用polylines把有效点连接成折线。import json import cv2 import numpy as np def annot_to_mask(annot, img_w640, img_h360, thickness8): mask np.zeros((img_h, img_w), dtypenp.uint8) h_samples annot[h_samples] # Tusimple标注坐标基于1280x720原图先缩放到目标分辨率 scale_x, scale_y img_w / 1280.0, img_h / 720.0 for lane_x in annot[lanes]: pts [] for x, y in zip(lane_x, h_samples): if x -2: # -2 表示该行无车道线 pts.append((int(x * scale_x), int(y * scale_y))) if len(pts) 2: # 少于2个点无法连线 pts np.array(pts, dtypenp.int32).reshape(-1, 1, 2) cv2.polylines(mask, [pts], isClosedFalse, color1, thicknessthickness) return maskthickness建议取8到12像素。Tusimple原始标注是单像素采样点如果不膨胀成线每张图的车道线像素占比会低于1%正负样本极度失衡。注意坐标缩放这步容易漏标注里的x、y都是720p坐标系下的值直接画到640×360的mask上会整体偏移。另一个容易忽略的细节是raw_file字段带clips前缀路径读取时要拼接数据集根目录。3.2 数据增强与Dataset封装车道线分割的增强要克制。水平翻转最有效它把左转和右转弯道做了对称扩充亮度扰动模拟不同光照随机平移模拟车辆在车道内的横向偏移。翻转时图像和mask必须同步做flip否则标签和图像错位模型会学到车道出现在错误一侧的错误映射。亮度扰动用convertScaleAbs实现beta超过±20会把路面纹理抹平反而增加误检。import os import json import random import cv2 import numpy as np import torch from torch.utils.data import Dataset class TusimpleDataset(Dataset): def __init__(self, root, anno_file, size(640, 360), trainTrue): self.root, self.size, self.train root, size, train self.samples [] with open(os.path.join(root, anno_file), r) as f: for line in f: self.samples.append(json.loads(line)) def __len__(self): return len(self.samples) def __getitem__(self, idx): obj self.samples[idx] img cv2.imread(os.path.join(self.root, obj[raw_file])) img cv2.resize(img, self.size, interpolationcv2.INTER_LINEAR) mask annot_to_mask(obj, *self.size) if self.train: if random.random() 0.5: # 水平翻转mask同步翻转 img cv2.flip(img, 1) mask cv2.flip(mask, 1) if random.random() 0.5: # 亮度扰动 alpha random.uniform(0.8, 1.2) beta random.randint(-15, 15) img cv2.convertScaleAbs(img, alphaalpha, betabeta) img torch.from_numpy(img.transpose(2, 0, 1).copy()).float() / 255.0 mask torch.from_numpy(mask.astype(np.float32)).unsqueeze(0) return img, masktranspose之后必须调用.copy()因为torch.from_numpy不接受非连续内存的数组这个报错是初学者最常见的第一个坑。mask保持float类型直接和BCEWithLogitsLoss的float target对齐不需要转one-hot。增强全部走cv2而不是PIL后续做仿射变换时cv2的变换矩阵可以同时作用在图像和mask上代码更短。3.3 训练主循环与超参数表训练主循环的骨架如下验证部分每个epoch跑一次。import torch from torch.utils.data import DataLoader model UNet(in_ch3, out_ch1, base64).cuda() criterion CombinedLoss() # 定义见第4章 optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max60) train_loader DataLoader( TusimpleDataset(data, train_label.json), batch_size8, shuffleTrue, num_workers4, drop_lastTrue, ) val_loader DataLoader( TusimpleDataset(data, test_label.json, trainFalse), batch_size8, shuffleFalse, num_workers4, ) for epoch in range(60): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda() loss criterion(model(img), mask) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step() # 每个epoch结束后跑验证记录Acc和F1clip_grad_norm_把梯度范数限制在5.0防止个别异常样本把loss炸到数百。train_loader里的drop_lastTrue保证每个batch的BN统计量接近验证集不足一个batch时仍要正常评估。下面是一份可以直接起步的超参数表。参数推荐值说明输入尺寸512×256 或 640×360保持16:9避免挤压变形batch size816低于8时BN统计量抖动明显初始学习率3e-41e-3起步会看到loss先升后降权重衰减1e-4只作用于卷积层BN层置0标注厚度812像素太细正样本不足训练轮数6080约40轮后F1增益趋于平缓4. 训练调参loss组合、优化器与过拟合信号4.1 BCE与Dice组合loss二值分割里最常见的是BCE loss但Tusimple的车道线像素占比只有1%到5%纯BCE会让模型收敛到全预测为背景的局部最优。解决方向有两个给BCE加pos_weight放大正样本梯度或者叠加Dice loss直接优化区域重叠度。Dice loss对类别不平衡不敏感但在极端情况下梯度不稳定和BCE组合是分割任务的标准做法。import torch import torch.nn as nn class CombinedLoss(nn.Module): def __init__(self, dice_weight0.5, pos_weight3.0): super().__init__() self.dice_weight dice_weight self.bce nn.BCEWithLogitsLoss( pos_weighttorch.tensor(pos_weight) ) def forward(self, pred, target): bce self.bce(pred, target) prob torch.sigmoid(pred) inter (prob * target).sum(dim(2, 3)) union prob.sum(dim(2, 3)) target.sum(dim(2, 3)) 1e-6 dice 1 - (2 * inter 1e-6) / union return bce self.dice_weight * dice.mean()pos_weight3.0意味着前景的BCE梯度是背景的3倍这个值不需要精调2到8之间都有效。Dice项用的是sigmoid后的软概率而不是阈值后的硬mask梯度能完整回传到logits1e-6是平滑项防止某个batch里完全没有前景时除零。如果训练前几个batch loss不下降先把dice_weight调小到0.2再逐步加到0.5。4.2 优化器与BN层的权重衰减处理优化器建议直接选AdamW收敛快、对学习率不敏感。真正要处理的是BN层BN的gamma和beta若参与权重衰减会让归一化尺度持续被压缩表现是验证集指标上下抖动。常见的做法是把参数分成两个组bn_params [p for n, p in model.named_parameters() if bn in n] conv_params [p for n, p in model.named_parameters() if bn not in n] optimizer torch.optim.AdamW([ {params: conv_params, weight_decay: 1e-4}, {params: bn_params, weight_decay: 0.0}, ], lr3e-4)学习率调度用余弦退火即可但建议前3个epoch做线性warmup从1e-4上升到3e-4。不做warmup的话第一批数据就带着3e-4的学习率BN的running stats还没稳定容易出现loss先跳到1.5再缓慢下降的现象。调度周期T_max设为epoch总数减去warmup轮数保证最后一轮学习率降到接近0。warmup的实现就是在epoch循环开头加一个lr base_lr * (epoch 1) / warmup的赋值其余逻辑不变。4.3 验证指标与过拟合信号训练时记录的指标有两个像素准确率和F1。像素准确率容易虚高因为背景占比超过95%模型全预测背景也有0.95的准确率所以要以F1为主。计算F1时把mask阈值取0.5按像素逐点比较model.eval() tp fp fn 0 with torch.no_grad(): for img, mask in val_loader: pred (torch.sigmoid(model(img.cuda())) 0.5).float().cpu() tp ((pred 1) (mask 1)).sum().item() fp ((pred 1) (mask 0)).sum().item() fn ((pred 0) (mask 1)).sum().item() precision tp / (tp fp 1e-6) recall tp / (tp fn 1e-6) f1 2 * precision * recall / (precision recall 1e-6)过拟合信号出现的顺序一般是train loss继续下降、val F1开始徘徊再过几个epochval loss回升。Tusimple训练集只有3626张图UNet参数量在30M量级60轮左右过拟合是正常的。应对手段按优先级排增强强度加大翻转概率提到0.7、加随机平移、dropout加在bridge处、把base从64降到48。应该在训练前就按显存和验证集F1定好模型宽度而不是等过拟合出现了再缩。提示验证集和训练集不要共用视频序列。Tusimple的train和test按clip划分如果自己切分数据务必按clip分组而不是按帧随机切。同一clip的连续帧高度相似按帧切分会严重高估F1。5. 预测后处理从分割mask拟合出车道线曲线5.1 阈值化与按行提取车道线像素推理时输出的是H×W的logits图先经过sigmoid再取阈值阈值取0.3到0.5之间。阈值别取太高UNet对车道线边缘的预测是渐变的0.5会把细线拦腰截断。我一般用0.35宁可多留一些候选点交给后处理过滤。5.2 滑动窗口与多项式拟合提取到的二值图里可能粘连着多条车道线直接整体拟合会把不同车道拟成一条。先对下半部分做列直方图统计找到局部峰值峰值对应各条车道的起始x位置再用滑动窗口逐段向上追踪def extract_lanes(binary_mask, num_lanes4, window_h20): h, w binary_mask.shape hist binary_mask[h // 2:, :].sum(axis0) peaks [] for x in np.argsort(hist)[::-1]: # 按响应从大到小取峰值 if all(abs(x - p) 80 for p in peaks): peaks.append(int(x)) if len(peaks) num_lanes: break lanes [] for start_x in peaks: pts [] for y in range(h - window_h, 0, -window_h): x0, x1 max(0, start_x - 60), min(w, start_x 60) col_sum binary_mask[y:y window_h, x0:x1].sum(axis0) if col_sum.sum() 2: xs np.where(col_sum 0)[0] pts.append((int(xs.mean()) x0, y window_h // 2)) lanes.append(pts) return laneswindow_h20是滑动窗口的行高窗口总宽度120像素对应Tusimple里相邻车道线的典型间距。每个窗口内先对列求和再做平均能压制单帧mask的噪声。如果连续3个窗口都找不到点说明这条车道断裂了拟合时对断点做外推而不是丢弃尾部。拟合用np.polyfit([p[1] for p in pts], [p[0] for p in pts], 2)二次多项式足以描述Tusimple场景下的车道曲率三次多项式反而会把噪声拟合进去。Tusimple官方评测按标注点在车道线上的命中率计算验证预测效果时把拟合后的曲线按原图scale_x、scale_y映射回720p再和json里的采样点对齐计算TP和FP。5.3 效果验证与常见失败模式训练结束后最值得做的事是把预测结果按clip逐帧保存成视频肉眼观察车道线在弯道处是否平滑。F1高但视频里抖动的case原因通常集中在三类训练时标注宽度不一致、增强强度过高导致车道线被亮度扰动截断、预测阈值选错。一个实用技巧把UNet最后一层改成同时输出分割头和车道线端点热图的共享编码结构只在训练时附加端点损失推理时依然只走分割头。这个改动的成本只有几行代码但对Tusimple测试集上远距离车道线的F1提升最直接。本文还有配套的精品资源点击获取