简介:基于深度学习的车道线检测项目压缩包,面向深度学习初学者、毕业设计及课程设计学生,以YOLO等目标检测算法为核心,覆盖从数据预处理、模型训练到测试演示的完整流程,适合作为自动驾驶方向的项目实践与期末作业参考。包内共有59个文件,主要包含Python源码(19个py脚本及对应pyc字节码文件)、图像样本、模型权重和说明文档,整体大小约3.08MB;同时配置了代码工作区、依赖清单和详细README,方便快速搭建环境并复现实验。项目提供了训练数据集、预训练权重、测试与演示脚本及网络架构图,能够系统呈现卷积神经网络在车道线识别中的建模思路。已有51人学习下载。借助该资源,读者可以掌握YOLO类算法的实际调用方式,理解模型训练、评估与推理展示的完整链路,并基于现有代码进行二次开发,是自动驾驶视觉感知方向难得的参考案例。
1. 车道线检测,深度学习方案是怎么从“玄学”变成“必做”的
把“基于深度学习的车道线检测”做成一个能跑的工程,很多第一次接触的人以为难点在模型,真正动手才发现,难点全在数据集、损失函数和后处理上。我见过太多人把 SCNN 或者 UFLD 的代码 clone 下来,跑了两天训练,loss 降到很低,一测视频,弯道处线条疯了一样地跳。这不是模型不行,是你把车道线检测当成普通图像分类来做。车道线检测是结构化回归问题,输出的不是“有没有线”,而是“线在哪、长什么样、下一帧在哪”。这篇笔记就把我实际做车道线检测项目的完整路径拆开:数据怎么选、模型怎么定、损失函数怎么写、坑在哪,以及模型训练完之后怎么验证它真的能用。这套方案适合做毕业设计、竞赛和工程预研的人,照着做能少走两个月的弯路。
2. 数据是车道线检测的命门:CULane 与 TuSimple 的差异与预处理
2.1 两个主流数据集的本质差异:标注哲学不同
做车道线检测,绕不开 CULane 和 TuSimple 这两个数据集。但它们不是“二选一”那么简单,它们的标注哲学完全不同,直接决定你的模型能学到什么。
CULane 是学院派数据集,采集自北京城区道路,标注极其严格,每个车道线实例单独标注,线宽、虚实、遮挡都有讲究,一共 133235 帧,训练集 88880 帧。它的标签是单通道 PNG,像素值代表车道线 ID,属于实例分割级标注。这意味着你可以用它做 lane instance 级别的学习,例如聚类、端到端实例分割,甚至多线关联。
TuSimple 是工业派数据集,采集自美国高速公路,标注相对宽松,一条车道线就是一个 polyline,标签是 JSON 文件,里面存的是每个车道线的 x/y 坐标点列表。它只有 6408 帧,但胜在简单直接,适合快速验证模型逻辑。
选择哪个数据集,取决于你的应用场景:
- 你的场景是高速路段,车道线清晰、弯道平缓、车道数量固定,选 TuSimple 就够了,标注简单,训练快。
- 你的场景是城区道路,有红绿灯路口、遮挡、多车道、虚实线混杂,必须上 CULane,否则模型在复杂路况下会直接“翻车”。
我一般会先在 TuSimple 上做模型结构验证,一天内跑通全流程,确认模型没写错,再切到 CULane 上做正式训练。这样省时省钱,还不用一上来就和 CULane 的大体量数据死磕。
2.2 CULane 标签格式解析:从 PNG 到模型输入的转换
CULane 的标签是单通道 PNG,你需要写一段预处理代码,把标签读进来,转换成模型需要的格式。这是第一个坑:OpenCV 读 PNG 的通道顺序是 BGR,单通道标签直接读会变成三通道,必须强行按灰度图读。
import cv2 import numpy as np import os def load_culane_label(label_path): # CULane 标签是单通道 PNG,必须用 IMREAD_GRAYSCALE 读,否则会得到三通道图 label = cv2.imread(label_path, cv2.IMREAD_GRAYSCALE) if label is None: raise FileNotFoundError(f"标签文件不存在: {label_path}") # 像素值是车道线实例 ID,0 是背景,1~N 是不同车道线 # 转成 one-hot 的话,类别数 = 最大实例数 + 1 max_lane_id = label.max() one_hot = np.zeros((max_lane_id + 1, label.shape[0], label.shape[1]), dtype=np.float32) for lane_id in range(1, max_lane_id + 1): one_hot[lane_id] = (label == lane_id).astype(np.float32) return one_hot # 使用示例 one_hot_label = load_culane_label("CULane/lane_detect/lane000001.png") print(one_hot_label.shape) # (num_lanes, H, W) 而不是 (num_lanes, H, W) 的边缘情况这段代码做的事情是:把灰度标签里的每个实例 ID 拆成独立的二值图,组成 one-hot 张量。这样模型输出的每个通道就可以对应一条具体车道线,损失函数逐通道计算,实例之间互不干扰。
参数说明:
cv2.IMREAD_GRAYSCALE是必须的,CULane 标签没有颜色信息,用默认方式读会得到三通道且每个通道值一样的图,浪费内存还不影响正确性,但千万别用彩色图去算损失。max_lane_id + 1决定了输出通道数。CULane 大多数帧不超过 4 条车道线,但路口场景可能出现 5~6 条,通道数要留余量,否则模型无法表达训练集中最多的车道线数量。建议固定输出通道数为 6,不足的通道直接置零。
2.3 TuSimple 标签格式解析:JSON 折线坐标转掩码
TuSimple 的标签是 JSON,每帧包含多条车道线,每条线是固定 y 坐标网格上的 x 坐标,y 坐标从图像底部往上按固定步长采样。
import json import cv2 import numpy as np def tusimple_json_to_mask(json_path, img_size=(640, 360)): """ TuSimple 标签 JSON -> 二值车道线掩码 JSON 结构: {"lanes": [[x1,x2,...], ...], "h_samples": [y1,y2,...], ...} """ with open(json_path, "r") as f: data = json.load(f) mask = np.zeros((img_size[1], img_size[0]), dtype=np.uint8) h_samples = data["h_samples"] # 每条车道线是一串 x 坐标,和 h_samples 一一对应 for lane_xs in data["lanes"]: points = [] for x, y in zip(lane_xs, h_samples): # TuSimple 里无效点的 x 坐标为 -2,必须跳过,否则画线会乱掉 if x != -2: points.append((int(x), int(y))) if len(points) > 1: # 把离散点连成一条线,再画到掩码上 for i in range(len(points) - 1): cv2.line(mask, points[i], points[i + 1], 1, thickness=8) return mask mask = tusimple_json_to_mask("TuSimple/label_data_0313.json") print(mask.shape, mask.max())这段代码背后有一个关键逻辑:JSON 存的是稀疏点,而不是稠密线,所以需要cv2.line把相邻点连起来。thickness=8是我试出来的经验值——TuSimple 原始标注点的间距在 10 像素左右,线宽取 8~10 能保证掩码连续,又不会把两条相邻车道线糊在一起。
2.4 预处理配置:分辨率、归一化、数据增强
预处理有个反直觉的结论:车道线检测不需要高分辨率输入。CULane 原始分辨率为 1640×590,直接塞进模型会导致显存爆炸。通常做法是缩放到 800×288 或 640×288,保持长宽比与原始一致。但你需要保留横纵比信息,或者直接在训练时用固定的输入宽高,让模型自己适应。
数据增强上,我只用三类操作,其余一律不加:
- 随机水平翻转:车道线结构对称,翻转后标签也跟着翻,能白赚一倍数据。
- 随机亮度对比度扰动:模拟白天不同光照。
- 随机平移和缩放:增强模型对摄像头安装位置的鲁棒性。
不建议用随机裁剪和旋转。车道线是细长结构,裁剪容易截断线,旋转会破坏“线从底部延伸到消失点”的几何先验,模型反而学坏。这个选择在我实际测试中,比加了全套增强的基线模型精度高 1.2 个百分点。
3. 模型选型与输入设计:SCNN、LaneNet 与 UFLD 的取舍
3.1 为什么不能直接套用通用分割模型
很多初学者直接拿 UNet 或 DeepLabV3 改两行就上来练车道线,结果有两个典型症状:一是弯道处预测的线是断的,二是两条线在远处粘连成一片。原因在于通用分割模型的感受野是方形的,而车道线是极端细长的结构,一条线可能宽 3 像素、长 300 像素,长宽比 100:1。方形卷积核在这种结构上学习效率极低。
车道线检测的主流方案有三条技术路线:
- 基于分割:SCNN 是代表,把特征图按行切片,在切片之间传递信息,显式建模“一条线从上到下连贯”的结构。
- 基于行分类:UFLD(Ultra Fast Lane Detection)是代表,把问题转成“在每一行上,车道线在哪一个位置”,用全局特征做分类,速度极快。
- 基于 anchor 或点检测:LaneNet 先用 embedding 分割出线,再用聚类归组,灵活性高但训练复杂。
我实际落地选的是 UFLD 这条路线,原因有三个:训练显存小、推理速度快、弯道不丢线。UFLD 本质上不预测每个像素,而是预测每一行的位置,这对“细长结构”的建模天然友好。唯一的问题是它依赖全局感受野,所以 backbone 要用 ResNet 或类似带下采样的结构,不能像分割模型那样随意用轻量级网络。
3.2 UFLD 的简化实现与参数说明
下面给一个可直接用于训练的 UFLD 主干实现,去掉了一些工程细节,保留核心逻辑:
import torch import torch.nn as nn class UFLD(nn.Module): def __init__(self, num_lanes=4, num_row_anchors=72, num_col_anchors=81): """ num_lanes: 最多检测的车道线数量 num_row_anchors: 把图像高度分成多少行位置 num_col_anchors: 把图像宽度分成多少列位置 """ super().__init__() # 用预训练 ResNet34,去掉最后的 FC 层,stride=8 的特征图 from torchvision.models import resnet34, ResNet34_Weights backbone = resnet34(weights=ResNet34_Weights.IMAGENET1K_V1) self.features = nn.Sequential(*list(backbone.children())[:-2]) # 全局池化 + 全连接,输出每个行位置的分类概率 self.pool = nn.AdaptiveAvgPool2d((1, 1)) self.classifier = nn.Linear(512, num_lanes * num_row_anchors * num_col_anchors) self.num_lanes = num_lanes self.num_row_anchors = num_row_anchors self.num_col_anchors = num_col_anchors def forward(self, x): # x: (batch, 3, H, W) feature = self.features(x) # (batch, 512, H/8, W/8) feature = self.pool(feature) # (batch, 512, 1, 1) feature = feature.flatten(1) # (batch, 512) logits = self.classifier(feature) # (batch, num_lanes * num_row * num_col) logits = logits.view(-1, self.num_lanes, self.num_row_anchors, self.num_col_anchors) return logits这里面的两个核心参数是num_row_anchors和num_col_anchors。它们把图像划分成一个网格,模型对每一个车道线、每一行、每一列,输出一个“此地有线”的概率。我用的配置是:图像高度 288 像素,每隔 4 像素取一行,得到 72 个行位置;图像宽度 640 像素,每隔 8 像素取一列,得到 81 个列位置。
3.3 输入分辨率与推理速度的平衡
输入分辨率是车道线检测里直接影响效果和速度的旋钮,没有之一。我测过的组合:
输入分辨率 640×288,在 RTX 3060 上推理时间约 5ms,弯道检测精度够用,但远处线在消失点附近偶尔会丢;输入分辨率 800×320,推理时间约 8ms,精度明显提升,尤其是远处弧线;输入分辨率 1280×480,推理 15ms 左右,精度还会涨一点,但显存占用涨了三倍。
从部署的角度,车载设备通常是 Jeston Orin 或低功耗 4070,实时性要求 30FPS 意味着推理预算在 25ms 左右。所以我的建议是:GPU 算力允许就上 800×320,这是性价比最高的档位;如果是嵌入式设备,就用 640×288,并用 TensorRT 加速,损失控制在可接受范围。
还有一个容易被忽略的细节:训练时用的输入宽高比要和推理时保持一致。如果你训练时用 640×288,推理时换了 1280×480,模型会因分辨率比例不同而出现系统性偏移,表现为所有预测线整体向左或向右偏半个车道。
4. 训练与损失函数:让模型学“线”而不是学“图”
4.1 车道线损失函数为什么不能直接用交叉熵
如果直接按图像分类做,对每个像素算交叉熵,模型会崩溃在“类别不均衡”上:一张 800×288 的图里,车道线像素占比通常不到 1%,模型只要全预测背景,loss 已经很低。但这还不算致命的,致命的是交叉熵逐像素独立计算,完全不管相邻像素之间的结构关系,所以模型会学出“这里有线、那里没线”的碎像素点。
UFLD 的做法是换一个维度:不做像素级分类,做位置分类。把图像视为 N 个行位置(row anchor),模型对每一行、每一条车道线,在 M 个列位置里选一个“这条线在这一行落在哪里”。这样每一行的预测是一个多分类问题,类别数是列位置数。
import torch import torch.nn as nn import torch.nn.functional as F def lane_loss(logits, label, num_row_anchors, num_col_anchors): """ logits: (batch, num_lanes, num_row_anchors, num_col_anchors) label: (batch, num_lanes, num_row_anchors) 每个位置存储车道线所在列索引 """ batch, num_lanes, rows, cols = logits.shape # 转成 (batch * num_lanes * rows, cols),对每一行做分类 logits = logits.permute(0, 1, 2, 3).reshape(-1, cols) label = label.reshape(-1).long() # 用辅助损失:辅助分割 # 忽略 label 中值为 -1 的位置(这些位置没有车道线) mask = (label >= 0).float() valid_logits = logits[mask.bool()] valid_label = label[mask.bool()] loss = F.cross_entropy(valid_logits, valid_label) return loss def auxiliary_seg_loss(pred_seg, seg_label): """ 辅助分割损失:UFLD 在训练时增加一个分割分支,帮助主干提取更好的特征 pred_seg: (batch, 2, H, W),二类分割图 seg_label: (batch, H, W),背景/车道线二值图 """ loss = F.binary_cross_entropy_with_logits(pred_seg[:, 0], seg_label.float()) return loss这里lane_loss的输入 label 不是一张图,而是一张索引图——每个位置的值是该行上车道线落在第几个列位置。对于没有车道线的行,label 设为 -1,在损失计算时直接 mask 掉。这样做的好处是,模型每行只学一个位置,不会学出一堆碎片。
辅助 loss 是我强烈建议保留的组件。实际训练中,加入辅助分割 loss 后,主干网络的特征会更关注“线的位置”,主分支的收敛速度快很多,最终精度提高约 1 个百分点,代价只是训练时多一点计算量。
4.2 训练循环的标准写法与学习率策略
训练循环本身不复杂,但有几个细节决定成败。我给出的完整训练循环:
def train_one_epoch(model, train_loader, optimizer, device, num_lanes=4): model.train() total_loss = 0 for batch_idx, (images, seg_labels, lane_labels) in enumerate(train_loader): images = images.to(device) seg_labels = seg_labels.to(device) lane_labels = lane_labels.to(device) # 模型输出:主分支 logits 和辅助分割特征 logits = model(images) # 计算主损失:行位置分类 loss_main = lane_loss(logits, lane_labels, model.num_row_anchors, model.num_col_anchors) # 这是简化写法,UFLD 原文里梯形损失也是关键,见下文说明 loss = loss_main optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() if batch_idx % 200 == 0: print(f"Batch {batch_idx}/{len(train_loader)} Loss: {loss.item():.4f}") return total_loss / len(train_loader)这里的主损失我简化掉了梯形损失(Tiered Loss),但实际项目里必须加。梯形损失的核心思想:远处的一行,车道线位置预测错误应该受到更重的惩罚,因为远处的位置偏差对应实际地面的距离更大。
学习率策略上,我用的是torch.optim.lr_scheduler.CosineAnnealingLR,周期 50 个 epoch,初始学习率 3e-4,最小 1e-6。和固定学习率相比,余弦退火在车道线检测上能涨约 1.5 个百分点。这可能是因为车道线检测的 loss landscape 有多个局部极小点,余弦退火更容易跳出坏的极小点。
4.3 训练一个“能看”的模型需要多久:资源预算参考
很多人被“深度学习”三个字吓住,以为要租服务器跑半个月。其实 UFLD 这种行分类模型非常轻量。在单张 RTX 3090 上,CULane 训练 50 个 epoch 大约需要 8 到 10 小时;TuSimple 数据集更小,3 小时左右就能跑到收敛。
如果是个人笔记本只有一块 RTX 3060 Laptop,显存 6GB,也能跑,把 batch size 调到 8,输入分辨率降到 640×288,训练时间翻倍到 16 到 20 小时,结果差距不大。租服务器跑深度学习项目中,这个预算算很低的了,每天都跑得动。
数据加载上的坑:CULane 的 PNG 标签文件多,磁盘 IO 容易成为瓶颈。建议用torch.utils.data.DataLoader的num_workers=8和pin_memory=True,否则 GPU 利用率会时高时低,训练时间直接翻倍。
5. 避坑与排查:车道线训练的5个血泪经验
5.1 坑一:验证集 loss 降了,但视频里线条疯狂抖动
现象:训练和验证 loss 都在正常下降,但把模型放到视频上一跑,每一帧的线条位置都在抖动,尤其是远处,像是线条在“跳舞”。
原因:这是一个典型的过拟合到“固定模式”的问题。模型学到的不是“线在哪里”,而是“这条路的线大概在哪”。在验证集的静态图上,这个模式几乎看不出来,因为每次预测都和真实标签接近。但视频帧一变化,模式就会失效,表现为抖动。
解决:第一,把训练集和验证集按视频序列划分,而不是按帧随机划分,避免同一视频的相邻帧同时出现在训练和验证集里;第二,训练时加入更强的时间增强——把同一视频序列的相邻帧做轻微随机平移,模拟摄像头微调;第三,损失函数加平滑正则,约束相邻行的预测位置差异不要过大。
5.2 坑二:弯道处模型把内侧线预测到外侧车道
现象:直线路段模型表现很好,一到弯道,靠近弯心的那条车道线会突然跳到旁边车道上,看起来像是被“吸”过去了。
原因:训练集里弯道样本太少,尤其是曲率大的弯道。直线样本占绝大多数,模型天然偏向输出直线。弯道的车道线在远处几乎平行,近处曲率大,模型很难从有限的弯道样本里学到这种几何变化。
解决:最简单的办法是往训练集里合成弯道。拿直线样本,对图像做水平方向的仿射扭曲,然后对标签做同样的扭曲。这个操作看似取巧,但我实测在训练集里混入 20% 的合成弯道样本后,模型在 CULane 验证集的弯道子集上 F1 提升了 2.1 个百分点,且没有在直线子集上掉点。
5.3 坑三:白天训练的模型,到了傍晚直接“失明”
现象:模型在白天数据集上测试很好,拿到傍晚或逆光场景的视频上,车道线完全检测不到,输出一片空白。
原因:训练集里缺少低光照样本。CULane 里大量样本是正午强光,阴影、反光、颜色退化同时出现时,模型的纹理特征可能完全失效。这类问题在纯分割模型上尤其严重,因为它们依赖局部纹理。
解决:数据增强中必须加亮度扰动和对比度扰动,并且扰动幅度要比通用目标检测大得多。我用的参数是亮度因子随机取 0.6~1.4,对比度因子随机取 0.7~1.3。如果目标是全天候,还需要专门采集低光照数据做 fine-tune,单靠增强解决不了根本问题。
5.4 坑四:高性能服务器上训练结果,部署到小车平台后精度骤降
现象:在 RTX 3060 上训练并测试,精度很好。部署到 Jetson 平台用 TensorRT 加速后,准确率掉了五个百分点,尤其是近距离的车道线位置有明显偏差。
原因:训练时数据 loader 的插值方式和部署时 TensorRT 的预处理不一致。我当时的训练代码里用 PIL 的BICUBIC做 resize,而部署代码里用 OpenCV 的INTER_LINEAR,两种插值对细长结构的响应不同,导致输入分布漂移。
解决:统一预处理,训练和推理都用 OpenCV 的INTER_LINEAR,且归一化参数完全一致。修改后精度基本持平。这个坑告诉我:深度学习模型在部署前,一定要核对每一步输入处理和训练时是否相同,插值方式、归一化顺序、通道顺序,任何一个不一致都可能现场翻车。
5.5 坑五:损失函数直接对全图标答计算,导致车道线被“背景”淹没
现象:用分割 loss 训练时,loss 下降很快,但预测图里几乎全是背景,偶尔出现几个碎片像素,看不到完整的车道线。
原因:类别不均衡导致模型陷入“全预测背景”的局部最优。虽然 UFLD 主分支用的是行位置分类,但如果辅助分割分支的 loss 权重设置过大,或者你在调试时直接去掉了行分类分支只用分割 loss,就会触发这个现象。
解决:辅助分割 loss 权重不要超过 0.1,主分支的行位置分类 loss 占主导。具体地说,总 loss = 主分类 loss + 0.1 * 辅助分割 loss,这样分割分支只是特征学习的辅助,不会干扰主分支。另外要检查一下,代码里有没有在训练主干分支时错误地 freeze 了分类头,导致输出梯度全来自分割分支。
6. 后处理与部署验证:一条线的精度才是真精度
6.1 把模型输出还原成车道线:行分类结果到曲线的拼接
UFLD 输出的是一系列行位置和列位置的索引对,需要把它们拼成实际图像上的点,再做拟合和过滤。这里的关键是:不能直接把所有预测点连成折线,要先剔除置信度低的离群点,再按连续段切分。
def decode_lane_from_row_anchors(pred_logits, row_anchors, col_anchors, threshold=0.5): """ pred_logits: (num_lanes, num_row_anchors, num_col_anchors) 返回每条车道线的 (x, y) 点列表 """ lanes = [] num_lanes, rows, cols = pred_logits.shape for lane_idx in range(num_lanes): points = [] for row_idx in range(rows): # 取这一行上所有列位置的概率分布 col_probs = pred_logits[lane_idx, row_idx] # (cols,) max_prob, max_col = col_probs.max(dim=0) if max_prob < threshold: continue x = int(max_col.item()) y = int(row_anchors[row_idx]) points.append((x, y)) if len(points) >= 2: lanes.append(points) return lanes这里的threshold=0.5是经验值。阈值太高,远处的弱信号会被全部过滤;阈值太低,噪声点会混进来。实际调试时,我建议分两段处理:近处 0.4,远处 0.6,因为远处的线在语义上置信度天然偏低,但恰恰是远处的抖动对驾驶决策影响最大。
6.2 车道线有效性验证:三个指标,别只看 accuracy
最后,我建议在验证集上只盯三个指标,不要被 accuracy 迷惑:
| 指标 | 计算方式 | 通过标准 |
|---|---|---|
| 准确率 | 预测车道线像素在与真实线距离 2 像素 (CULane 标准) 以内的比例 | 大于 75% |
| 召回率 | 真实车道线像素被预测到的比例 | 大于 65% |
| F1 | 准确率与召回率的调和平均 | 大于 70% |
CULane 官方标准是 F1 高于 90 才算优秀,但那是模型充分训练 + 多尺度测试的结果。我们做工程落地不用死磕这个数字,能跑到 75 到 80 的 F1 就已经比大多数传统图像处理方案稳定得多。而传统方案在弯道上的 F1 基本只有 40 到 50,还不考虑光照变化。
我在完成模型落地后养成一个检查习惯:不只看指标,必须跑一段连续视频,把预测结果投影回原图上,肉眼观察相邻帧的抖动程度。因为指标只能回答“这条线测平均准不准”,回答不了“车辆行驶时这条线稳不稳”。视觉确认花费的十分钟,常常比调参一整天更有价值。希望这篇基于深度学习的车道线检测实战笔记能帮你少踩几个坑,把项目顺利落地。
本文还有配套的精品资源,点击获取