ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于U-Net的语义分割车道线检测实战:从数据到部署

基于U-Net的语义分割车道线检测实战:从数据到部署 简介基于语义分割的车道线检测项目完整提供Python源码与说明文档面向计算机视觉、深度学习方向的毕设学生与工程师帮助解决车道线精准识别与分割的落地实现问题。压缩包共32个文件大小1.35MB包含8个Python脚本训练、测试、数据预处理、模型定义、多个GCN/ERFNet模型权重文件meta、index、data-00000-of-00001、阈值分割效果对比图PNG以及README说明文档结构清晰便于复现与二次开发。项目采用GCN、ERFNet等语义分割网络并提供threshold_0.5/0.7/0.9可视化输出方便观察阈值对检测结果的影响。目前已有65人学习下载适合作为课程设计、毕业设计或车道线检测技术入门的参考资料。通过实际操作读者可掌握数据准备、模型训练、权重加载与效果评估的完整流程并结合源码注释与项目说明快速理解关键算法。1. 为什么车道线检测最后都绕回语义分割从“找线”到“理解画面”车道线检测在车载视觉里是个挺有意思的异类它看起来像目标检测但车道线不是“物体”没有固定宽高比还经常被车头挡住一半它看起来像传统图像处理但光照、磨损、雨雪一变边缘检测那套参数就崩。语义分割的思路是彻底换一个问法——不去“找线”而是对每个像素做分类这个像素属于左车道线、右车道线还是背景。问题从“线在哪”变成“哪些像素是线”模型学到的不是边缘特征而是上下文语义所以对遮挡、阴影、强光这类干扰要稳得多。这个项目标题表达的正是这种做法用语义分割模型做车道线检测Python 源码加上项目说明一起打包。无论你拿它来跑 demo、改自己的数据集还是把模型换掉做对比实验核心要理解的是一整套数据标注到模型推理的流程而不只是跑通一个 forward。适合的读者是已经会 Python 基础、想认真做一次语义分割落地的人能跟着步骤把训练、评估、后处理串起来也能看懂结果为什么好、为什么坏。下面按我自己的实践经验把这个方案的选型、实现和踩坑点完整过一遍。2. 把车道线检测拆成“逐像素三分类”标签设计与数据集处理2.1 为什么不是检测、不是分割实例而是语义分割先解决一个绕不开的疑问YOLO 系列现在也有实例分割为什么车道线这种场景还是语义分割更顺手你可以对比一下三者的输出差异目标检测输出的是框但车道线是细长条框的 IoU 对几条线并行的情况毫无区分能力实例分割会把每条线拆成独立实例但车道线经常断裂、重叠、被车辆遮挡实例 ID 的分配本身就很难稳定语义分割只回答“这个像素是不是车道线”不关心是哪一条输出稳定后处理里再做聚类或拟合把线整理出来就行。还有一个很现实的原因——标注成本。语义分割的标签只需要把车道线区域涂白、背景涂黑一张图几分钟就能标完实例分割要逐条线给 ID断裂的车道线到底是同一条还是两条标注员得反复对上下文。真实项目里数据的迭代速度往往比模型结构更影响最终效果。所以在网络结构上这个方向常用的就是 U-Net 或者 DeepLabV3 这类语义分割模型输入一张 RGB 图输出和原图同尺寸的 mask每个像素取 argmax 得到类别。具体到车道线任务类别一般是三类背景、左车道线、右车道线如果只需要单条本车道线那就两类。分类数越少模型越容易收敛这也是为什么第一版 demo 不建议直接上多车道线分类。2.2 数据集从哪来TuSimple 格式解析与本地自定义数据集开源的 TuSimple 车道线数据集是这个方向最常见的起点。它的标注是以 JSON 文件组织的每张图片对应一个 json里面有 lanes每条线的 x/y 坐标点、h_samples固定的 y 坐标采样值和 raw_file图片路径。读的时候要注意lanes 里的每个元素是一条线的所有 x 坐标长度和 h_samples 一致值为 -2 表示该 y 位置没有采样点。我一般会写一个转换脚本把 TuSimple 的曲线标注转成语义分割的 mask 图逻辑是从 json 里取到每条线的点集用 [cv2.polylines]( 画线再cv2.fillPoly填充成有宽度的区域最后按类别写入 mask。核心代码如下import json import cv2 import numpy as np import os def tusimple_to_mask(json_path, h_samples, img_shape, thickness8): :param json_path: TuSimple 标注 json 路径 :param h_samples: TuSimple 固定的 y 坐标列表从标注文件读取 :param img_shape: (H, W) 原始图片尺寸 :param thickness: 车道线填充宽度单位像素一般 6~10 :return: mask 单通道图0背景1左车道线2右车道线 with open(json_path, r, encodingutf-8) as f: anno json.load(f) mask np.zeros((img_shape[0], img_shape[1]), dtypenp.uint8) lanes anno[lanes] for idx, lane_x in enumerate(lanes): pts [] for x, y in zip(lane_x, h_samples): if x ! -2: # -2 表示该 y 高度没有车道线采样 pts.append([int(x), int(y)]) if len(pts) 2: continue pts np.array([pts], dtypenp.int32) line_type 1 if idx 0 else 2 # 约定第 0 条为左车道线其余为右 cv2.polylines(mask, pts, isClosedFalse, colorline_type, thicknessthickness) return mask # 使用示例 h_samples [i for i in range(0, 720, 10)] # 按实际 json 里的 h_samples 为准 mask tusimple_to_mask(label.json, h_samples, (720, 1280)) cv2.imwrite(mask.png, mask * 60) # 乘系数是为了可视化时人眼可分辨逻辑说明这段脚本先把每条车道的 x/y 点拼成点集然后画线加粗到 8 个像素宽度。thickness 是一个直接影响分割效果的参数——太细比如 2 像素正负样本比例严重失衡模型很容易把所有像素都预测成背景太粗比如 20 像素评估时算 IoU 很虚高真实感知距离误差也大。经验上 8 像素左右在 1280x720 分辨率下比较合理。如果你没有开源数据集想用自己的行车记录仪视频流程是抽帧、清洗、用 LabelMe 或 CVAT 标注导出的 JSON 转成 mask。注意标注时不要只涂线的中心骨架要把车道线被磨损、被阴影遮住的边缘也标进去否则模型会误以为“亮白色的才是车道线”对阴影里的线直接漏检。这一步的图省事会在后面训练时加倍还回来。3. 用 PyTorch 从零跑通 U-Net 车道线分割模型搭建、训练与评估3.1 选 U-Net 而不是 DeepLabV3 的原因显存、收敛速度、上手难度U-Net 在医学图像、遥感图像这些“小数据集 精细边界”的任务里表现一直很稳车道线恰好符合这两个特征。DeepLabV3 用空洞卷积扩大感受野对大目标更友好但车道线本质上是细长结构过大感受野反而会把远处模糊的线“平滑”掉。另一个实际原因是显存DeepLabV3 的 ASPP 模块在 1280x720 分辨率上跑batch size 稍微一调就爆显存U-Net 的编码器-解码器结构更轻配合半精度训练可以稳定跑到 batch size 8 以上。U-Net 的核心是下采样四次的编码器特征图从 3 通道变到 512 通道分辨率缩到 1/16和上采样对称的解码器跳跃连接把同尺度的底层特征拼回来。这样设计的直接收益是浅层信息车道线的边缘、纹理能一路传到输出层不会在多层卷积里被稀释掉。对车道线这种“细节决定成败”的任务Skip Connection 比什么都重要。模型结构我直接引用一个精简的 U-Net不依赖额外的模型库方便你把代码拆开看每一步import torch import torch.nn as nn class DoubleConv(nn.Module): 两次卷积 BN ReLUU-Net 的基本模块 def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels3, num_classes3): super().__init__() # 编码器下采样路径 self.enc1 DoubleConv(in_channels, 64) self.enc2 DoubleConv(64, 128) self.enc3 DoubleConv(128, 256) self.enc4 DoubleConv(256, 512) self.pool nn.MaxPool2d(2) # 瓶颈层 self.bottleneck DoubleConv(512, 1024) # 解码器上采样路径 self.up4 nn.ConvTranspose2d(1024, 512, kernel_size2, stride2) self.dec4 DoubleConv(1024, 512) self.up3 nn.ConvTranspose2d(512, 256, kernel_size2, stride2) self.dec3 DoubleConv(512, 256) self.up2 nn.ConvTranspose2d(256, 128, kernel_size2, stride2) self.dec2 DoubleConv(256, 128) self.up1 nn.ConvTranspose2d(128, 64, kernel_size2, stride2) self.dec1 DoubleConv(128, 64) self.out nn.Conv2d(64, num_classes, 1) def forward(self, x): # 编码 e1 self.enc1(x) # 1/1 e2 self.enc2(self.pool(e1)) # 1/2 e3 self.enc3(self.pool(e2)) # 1/4 e4 self.enc4(self.pool(e3)) # 1/8 # 瓶颈 b self.bottleneck(self.pool(e4)) # 1/16 # 解码每一步先上采样再与对应编码器特征拼接 d4 self.up4(b) d4 torch.cat([d4, e4], dim1) d4 self.dec4(d4) d3 self.up3(d4) d3 torch.cat([d3, e3], dim1) d3 self.dec3(d3) d2 self.up2(d3) d2 torch.cat([d2, e2], dim1) d2 self.dec2(d2) d1 self.up1(d2) d1 torch.cat([d1, e1], dim1) d1 self.dec1(d1) return self.out(d1)逻辑说明forward 里每一步上采样后都要torch.cat拼接编码器的同层输出这是 U-Net 的骨架逻辑。ConvTranspose2d 做上采样会带来棋盘格伪影但在这个任务里影响不明显因为车道线的输出本身就是一条带状的掩码不是精细的像素级纹理。如果你在意细节质量可以换成双线性插值上采样加 3x3 卷积代价是参数量增加。参数说明第一层通道数设为 64对大分辨率输入来说这是显存和表达能力的折中。如果把 64 改成 32显存能省一半但分割边界会明显变糊改成 128效果提升有限训练时间却接近翻倍。对车道线这种二值特征明显的任务64 起步就够了。3.2 训练脚本损失函数、数据增强与半精度训练前必须处理一个硬骨头类别不平衡。一张 1280x720 的图里车道线像素占比通常在 2%~5% 之间如果直接拿 CrossEntropyLoss 训练模型会学到“全部预测为背景”因为这样损失已经很低了。这也是为什么很多人跑公开源码第一轮 loss 下降很漂亮但 mask 全黑——损失函数被背景类主导了。解决方法是给前景类加权。PyTorch 的 CrossEntropyLoss 自带 weight 参数按类别像素占比的反比设置即可。下面给出完整的训练脚本核心部分import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset from torch.cuda.amp import GradScaler, autocast import cv2 import numpy as np import os # ---------- 1. 自定义 Dataset ---------- class LaneDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size(384, 640)): self.img_paths sorted([os.path.join(img_dir, f) for f in os.listdir(img_dir)]) self.mask_paths sorted([os.path.join(mask_dir, f) for f in os.listdir(mask_dir)]) self.img_size img_size def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img cv2.imread(self.img_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (self.img_size[1], self.img_size[0])) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) mask cv2.resize(mask, (self.img_size[1], self.img_size[0]), interpolationcv2.INTER_NEAREST) img img.astype(np.float32) / 255.0 img torch.from_numpy(img).permute(2, 0, 1).float() mask torch.from_numpy(mask).long() return img, mask # ---------- 2. 加权损失 ---------- def compute_class_weights(dataset, num_classes3): 统计每个类别的像素占比返回权重占比越小权重越大 counts np.zeros(num_classes) for _, mask in dataset: for c in range(num_classes): counts[c] (mask.numpy() c).sum() total counts.sum() weights total / (counts * num_classes) # 归一化 return torch.tensor(weights, dtypetorch.float32) # ---------- 3. 训练配置 ---------- model UNet(in_channels3, num_classes3).cuda() weights compute_class_weights(train_dataset) criterion nn.CrossEntropyLoss(weightweights.cuda()) optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) scaler GradScaler() # 半精度训练用的梯度缩放器 for epoch in range(30): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda() optimizer.zero_grad() with autocast(): # 半精度前向显存直接砍半 logits model(img) loss criterion(logits, mask) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step()参数说明img_size(384, 640)是训练分辨率原图 720p 直接训练显存吃紧下采样到一半已经能保留车道线的连续结构。如果你想在 1080p 上训练batch size 大概率得调到 2 以下这时 BN 的统计量会变得不稳定建议换成 GroupNorm。lr1e-3配合 AdamW 在 ResNet 编码器上会略高但 U-Net 这种从零训练的模型可以接受如果用了 ImageNet 预训练编码器要降到1e-4。还有一个容易忽视的配置mask 的 resize 必须用cv2.INTER_NEAREST。默认的双线性插值会把 0/1/2 三个类别插出小数生成不存在的“混合类别”训练出来的分割图边缘全是噪点。这个坑我见过太多次了。3.3 评估指标用 mIoU 和 Accuracy 为什么不够加一个“漏检率”语义分割的标准评估是 mIoU每个类别的 IoU 取平均但车道线任务不能只看 mIoU。原因是mIoU 对“预测了但位置偏移几个像素”的情况扣分很小而车道线的安全冗余要求极高——预测的车道线哪怕只偏了 10 个像素在 50 米外对应的横向偏差已经超过半米这是不能接受的。所以我一般在评估脚本里同时算三个指标mIoU整体分割质量、Pixel Accuracy背景类主导仅作参考、车道线中心线偏差把预测 mask 按列取质心和 GT 质心比欧氏距离超过 5 个像素记为漏检。中心线偏差才是真正能反映“这条线能不能用”的指标。计算方式如下def centerline_distance(pred_mask, gt_mask, threshold5.0): :param pred_mask: (H, W) 预测结果像素值为 0/1/2 :param gt_mask: (H, W) 真实标签 :param threshold: 质心距离阈值超过记为漏检 :return: 平均距离和漏检率 distances [] miss_count 0 total_rows 0 for y in range(gt_mask.shape[0]): # 只评估 GT 存在车道线的行 gt_cols np.where(gt_mask[y] 1)[0] pred_cols np.where(pred_mask[y] 1)[0] if len(gt_cols) 0: continue total_rows 1 if len(pred_cols) 0: miss_count 1 continue gt_center gt_cols.mean() pred_center pred_cols.mean() dist abs(gt_center - pred_center) distances.append(dist) if dist threshold: miss_count 1 avg_dist np.mean(distances) if distances else 0 miss_rate miss_count / total_rows if total_rows else 0 return avg_dist, miss_rate逻辑说明按行比较做质心距离比直接像素级 IoU 更能反映“线偏了没偏”。如果miss_rate超过 2%说明模型在远处或者强光下的稳定性不够这时候先去查数据的标注质量再考虑调模型复杂度。注意这里只评估类别 1左车道线因为左右线在对称场景下表现接近算一条线的偏差已经够判断模型健康度。4. 把模型“调稳”而不是“调高”训练车道线分割的 5 个关键参数很多从目标检测转过来的人训练语义分割时第一反应是堆 epoch、堆数据增强但车道线这个任务有几个参数是决定性别的顺序不对跑多少轮都是白费。第一个是 loss 的类别权重。前面提过背景类占比可能超过 95%。这里给一个具体公式权重 总像素数 / (该类别像素数 * 类别数)。用这个公式算出来的权重前景类权重通常是背景类的 20~50 倍。如果 mIoU 里背景类高得离谱比如 98% 以上而前景类低于 50%就是权重还不够狠直接乘一个 2 再试。第二个是数据增强策略。车道线对光照变化极敏感但对翻转不敏感左右翻转会改变左右逻辑这是最大陷阱。代码里如果用RandomHorizontalFlip一定要把 mask 的左右类别互换否则模型会学乱。我常用的是随机亮度扰动±30%、随机高斯模糊核大小 3~5、随机透视变换模拟车辆变道视角。随机裁剪是毒药因为裁剪会切断车道线连续性模型会把断线学成特征。如果显存允许宁可整图缩小也不要裁剪。第三个是学习率调整策略。U-Net 用 CosineAnnealing 比 StepLR 稳定尤其在 epoch 数 30~50 时。如果发现 loss 在中段震荡把初始 lr 降到 3e-4不用动 schedule。不要用 ReduceLROnPlateau语义分割的 loss 曲线本身有噪声plateau 判断会频繁误触发。第四个是输入尺寸与 batch size 的平衡。显存不够时优先降 batch size 到 4不要降分辨率到 320 以下。车道线在低分辨率下会断成碎点模型输出“虚线感”特别重。如果 batch size 降到 2 还是爆显存检查激活值内存峰值是否来自跳跃连接——U-Net 的 cat 层在 1280 分辨率下极吃显存可以尝试在解码器前加一层nn.MaxPool2d降低拼接特征图分辨率。第五个是类别数的选择。如果只想做本车道线检测二分类背景/车道线比三分类稳定得多。三分类在左右车道线外观相似时容易互相穿越后处理时需要额外聚类。源码给的是三分类但你可以直接改num_classes2把 mask 里 2 改成 1loss 权重重算一遍效果通常会有可见提升。这个改动花费不到 10 分钟值得作为第一件事尝试。5. 车道线分割最常见问题排查5 个我在实践中反复踩的坑5.1 预测 mask 全黑但 loss 在正常下降现象训练十几个 epoch 后输出 mask 可视化全是 0背景类但训练 loss 曲线漂亮地下降。原因类别不平衡 loss 权重没生效。多数情况是CrossEntropyLoss的 weight 传参格式不对用了 NumPy 数组而不是 Tensor或者 mask 的标签从 1 开始编号导致类别 0 缺失。解决打印一个 batch 的 mask 唯一值确认类别是 {0, 1, 2} 而不是 {1, 2, 3}再看 weight 是不是torch.float32类型且挂在正确 device 上。5.2 训练时 mIoU 一路涨测试时同一张图预测效果差得离谱现象在验证集上 mIoU 有 85%但拿训练集里的一张原图走推理分割结果惨不忍睹。原因绝大部分是预处理不一致。训练时做了标准化归一化到 0~1但推理脚本忘了做或者 resize 的插值方式不一致训练用区域插值推理用线性。另一个可能训练时图片是从 BGR 转 RGB推理时直接读了 BGR。解决把训练和推理的预处理封装成同一个函数强制复用。血泪经验不要相信“我复制过来了”这种口头保证直接 import 同一份代码。5.3 车道线预测出来是虚线断开特别碎现象预测 mask 上车道线不连续短线、断点密集后处理拟合出来也是一截一截的。原因训练分辨率太低或标注厚度太细。还有一个容易忽略的数据增强里的RandomErasing或 Cutout 会把线挖断模型学到的是“断着来也没关系”。解决先去掉所有遮挡类增强把标注 thickness 从 4 提到 8把训练分辨率提到 640。多数情况下这两个动作就能让线连续起来。5.4 左右车道线粘连语义分割输出一个大色块现象预测结果里类别 1 和类别 2 在图像中间区域连成一片分不清左右。原因透视关系下远处车道线在像素空间的间距很小模型感受野覆盖到两条线的中间区域时特征混淆。本质是三分类的决策边界在远处叠加了。解决后处理里加形态学腐蚀核大小 3x3, 迭代 2 次把粘连断开再按连通域取最大两块分别归类为左右线。同时检查标注——是不是标注图里左右线本身就粘连了如果是标注阶段用cv2.subtract把重叠区域归给离自车更近的一侧。5.5 强光下漏检阴影里也漏检但普通天气效果还行现象晴天直射、树荫遮挡场景下车道线区域预测为背景别的场景正常。原因训练集里缺少光照变化的样本模型学到的是“像素亮线”的捷径而不是“结构特征线”。解决给训练集做随机 gamma 变换gamma 范围 0.5~1.8并加入随机阴影块模拟树荫。这是数据层面最有效的动作比换任何模型结构都管用。我一般对光照增强样本做在线增强概率设到 0.5不用离线扩充省显存也省标注。6. 从分割掩码到“能用的车道线”后处理、可视化与验证技巧模型输出的掩码离“能用的车道线”还差一步后处理。车辆控制模块要的不是一张图而是车道线的曲线方程或者离散点。常见做法是mask 按列扫描取每行的质心点用 3 次多项式拟合。但有几个细节直接决定拟合质量。第一靠近车头部分的线宽和远处不同如果按整幅图均匀采样近处的点权重过大远处拟合会飘我一般只取 mask 中下部 70% 的区域做拟合。第二质心点要先做离群点剔除——比如某行质心与前后两行的质心横向差超过 20 个像素直接丢弃避免被误检的杂散点带偏。第三拟合用最小二乘但加上 Huber 损失对异常点的鲁棒性比纯 MSE 好得多。可视化验证也有讲究。把预测掩码直接叠加在原图上颜色失真不明显但如果把 mask 转为鸟瞰图IPM再叠加能看到车辆行进方向的清晰曲线形状这才是判断模型是否真正理解“车道线是平行等宽”的最直观方法。我之前调参时只看原图叠加结果远侧线略微外扩没发现切到鸟瞰图一眼就看出两条线不平行返回去调标注坐标校正参数问题就解决了。建议在你的项目里也留一个visualize_birdview.py把 10 张验证图的结果拼成网格每次训练完扫一眼比单看指标有用得多。最后分享一个习惯每次改模型或数据导出三样东西——验证集 mIoU、中心线偏差曲线、以及 8 张典型场景的预测图夜晚、雨天、逆光、磨损。对比的不是这一次有多好而是相对上一次改动的增量。这个记录习惯帮我在无数次参数调整里快速排除掉了无效改动也避免过“感觉好了但不知道哪里好了”的盲目迭代。希望帮到你——把这套流程跑通语义分割车道线的每一个环节就不再是黑匣子了。本文还有配套的精品资源点击获取
返回列表