Ultra-Fast-Lane-Detection 这个项目,我第一次看到是在一个自动驾驶讨论群里,当时有人贴了一张它在 CULane 上的精度榜单截图,说“没用分割,纯分类,跑得飞快”。我当时第一反应是不太信,车道线检测这种任务不做分割怎么做?后来把论文和代码翻了一遍,才明白它把车道线检测重新定义成了“按行分类”的问题,整个思路非常取巧,而且效果确实能打。复现这个项目的过程,我踩了不少坑,也把代码里一些关键细节捋清楚了,这篇就专门聊聊怎么从零把它跑起来,以及跑通之后怎么根据自己的场景做调整。
先说这个项目适合谁看。如果你在做自动驾驶感知、ADAS 相关课程设计、毕业设计,或者单纯想研究一个轻量级视觉模型怎么在嵌入式设备上实时推理,那 Ultra-Fast-Lane-Detection 是一个非常好的样本。它结构简单、依赖少、训练和推理速度都很快,而且官方公开了 CULane 和 TuSimple 两个经典数据集的完整训练配置,复现门槛比大多数论文代码低得多。即使你不是专门做车道线的,把它当成一个“分类代替分割”的典型案例来学习,也很有价值。
1. 整体设计思路拆解:为什么车道线检测能做成分类
1.1 核心思路:把车道线检测变成按行选位置
传统车道线检测的主流做法是语义分割,也就是对图像里的每一个像素预测它属于哪条车道线。这样做精度确实高,但计算量非常大,因为要处理的是整张图的逐像素输出,尤其在嵌入式平台上,一个 512x288 的分割头就能把算力吃干榨净。
Ultra-Fast-Lane-Detection 换了一个角度:它不去逐像素分类,而是把图像划分成固定的行(row)和列(anchor),然后对每一行预测车道线在该行上落在哪个列位置。换句话说,模型不是在“画”车道线,而是在“猜”车道线会经过哪些位置。这样做的好处很明显——输出维度从 HW 降到了 Crows,计算量直接少了一个数量级。
这个思路我第一次看的时候觉得有点反直觉,但仔细想想其实很合理。车道线本身是连续的细长结构,在每一行上通常只有一个确定的 x 坐标位置,所以“该行第几个格子有车道线”这种离散化建模方式,足够表达车道线的空间分布。而且因为不需要解码到像素级,模型可以用很小的分辨率跑,速度自然快。
1.2 为什么不做分割:计算量、全局信息、困难场景
把车道线检测做成行分类,除了快之外,还顺带解决了一个分割方法容易翻车的问题:感受野。
车道线检测里有一个经典的困难场景,就是车道线被车遮挡、磨损严重或者完全看不见。分割模型只能从局部像素的纹理去猜,容易把遮挡物边缘误判成车道线。但 Ultra-Fast-Lane-Detection 用的是全图特征加 row 方向的全局预测,每一行分类的时候都能看到整行的上下文信息,哪怕车道线那一小段被挡了,模型仍然能根据前后的连续性猜出它大概的位置。
另外,分割模型通常需要后处理把像素连通成线,而这种方法直接输出的是每个 row 上的位置点,天然就是结构化的坐标信息,省去了不少后处理逻辑。论文里还做了一个辅助的 segmentation branch 来帮助训练收敛,但推理的时候不需要它,所以不会增加额外的计算负担。
1.3 适用场景和前置要求
这个模型比较适合的场景是结构化道路上的车道线检测,比如高速公路、城市主干道这种车道线清晰、分布规律的环境。对于越野、无车道线或者严重逆光的情况,效果会打折扣,因为它本质上是靠学习“车道线在图像中的分布规律”来做预测的。
复现这个项目需要的基础知识包括:PyTorch 基本训练流程、卷积神经网络基础、图像数据集的组织方式。如果你本身跑过几个分类或者检测模型,那这套代码对你来说几乎没有难度。如果完全没接触过深度学习,建议先补一下 DataLoader 和训练循环的基础知识,再来复现会更顺畅。
2. 环境准备与依赖安装:版本匹配是第一道坎
2.1 基础环境版本选择
老规矩,先把环境搭好。我用的是这套组合,实测下来比较稳定:
| 组件 | 版本 | 备注 |
|---|---|---|
| Python | 3.8 | 3.9 也可以,但别用 3.10+,有些依赖编译会有问题 |
| PyTorch | 1.10.1 | 1.7 到 1.13 应该都行,但别太新,代码里有些 API 写法比较老 |
| CUDA | 11.3 | 配合 PyTorch 1.10.1,正好匹配 |
| torchvision | 0.11.1 | 跟 PyTorch 版本严格对应 |
| GCC | 7.5 | 编译 cupy 相关依赖时用 |
注意一点,代码仓库里没有提供 environment.yml 这种一键安装文件,需要自己手动装依赖。官方 README 写的很简单,但实际跑起来你会发现有些坑它没提到。
2.2 依赖安装细节
克隆仓库之后,先把 requirements 里的核心依赖装上。官方建议用 pip 直接装,我实际经验是这么装:
pip install torch==1.10.1+cu113 torchvision==0.11.1+cu113 -f https://download.pytorch.org/whl/cu113/torch_stable.html pip install opencv-python pillow numpy tqdm scikit-learn pip install tensorboard # 可选,训练日志可视化这里有个比较隐蔽的问题:torchvision的版本必须和torch严格对齐,否则后面跑验证代码时可能会在加载预训练权重时报错,原因就是模型结构里的 BN 层参数数量对不上。这个报错特别迷惑人,因为报的是size mismatch,会让人误以为是数据集的问题。
另外,代码里用了tensorboardX或者tensorboard来记录训练曲线,如果你的环境里没有装,训练启动后会报 ImportError。说实话,这个依赖不加也不影响训练,但为了方便观察 loss 变化,建议还是装上。
2.3 数据集准备:CULane 与 TuSimple 的格式差异
Ultra-Fast-Lane-Detection 官方支持两个数据集:CULane 和 TuSimple。两个数据集的标注格式完全不同,如果搞混了,训练出来的模型基本就是废的,而且评估指标也会莫名奇妙变成 0。
先看 TuSimple,它的标注是 JSON 格式,每条数据包含lanes(车道线点的 x 坐标列表)、h_samples(固定的 y 坐标列表)、raw_file(图像路径)。需要注意h_samples从图像顶部往下递增,每个lanes列表长度和h_samples一样,缺的点用 -2 或者 -1 填充。
再看 CULane,它没有统一标注文件,而是每个图像对应一个 .txt 文件,里面每一行代表一条车道线,坐标格式是x1 y1 x2 y2 x3 y3 ...,按单个点的 x 和 y 交替排列。而且 CULane 的 txt 文件里夹在list/train_split/、list/test_split/这些目录下,数据加载时会先读这些 list 文件来组织路径。
我在第一次复现时用的 TuSimple,结果训练到一半发现 loss 死活降不下去,查了半天才发现是数据加载时h_samples和实际图像的 y 坐标没对齐。后面换成官方的 CULane 预处理脚本才正常。
3. 代码结构拆解:模型、数据、损失函数三个核心
3.1 仓库目录结构一览
克隆下来的代码虽然不算多,但目录比较乱,我先帮大家梳理一下关键部分:
Ultra-Fast-Lane-Detection/ ├── configs/ # 训练配置文件 │ ├── culane.py │ └── tusimple.py ├── model/ │ ├── model.py # 主模型定义 │ ├── resnet.py # ResNet backbone │ └── seg_model.py # 辅助分割分支 ├── data/ │ ├── dataset.py # 数据加载 │ ├── transform.py # 图像预处理 │ └── mytransforms.py # 自定义变换 ├── train.py # 训练入口 ├── test.py # 测试入口 └── evaluate/ ├── lane.py # 车道线评估 └── culane.py # CULane 评估工具train.py是训练入口,test.py是测试入口,这两个文件都不大,建议精读。configs目录里的配置文件定义了所有超参数,包括输入图像的尺寸、backbone 类型、训练轮数、学习率策略等。
3.2 模型定义:从 backbone 到分类头
Ultra-Fast-Lane-Detection 的主干网络默认用的是 ResNet34,也支持 ResNet18、ResNet50,甚至可以用更轻量的 MobileNetV3 结构。模型大致分成三部分:
- backbone:输出特征图,分辨率是输入图像的 1/8 或 1/16。
- 分类头:把特征图池化到固定的 rows 个水平条带,对每个条带做列方向的分类,预测车道线落在哪个 anchor 位置。
- 分割辅助头:只在训练时使用,输出低分辨率的语义分割图,帮助模型更好地收敛。
这里的核心代码在model/model.py里的UFLDLaneNet类中。最关键的一个参数是num_row和num_col,分别对应 rows 分类数和 cols 分类数。CULane 配置里一般设置num_row=72、num_col=81,这个值决定了模型的输出粒度和计算量。如果你跑的是自定义数据集,这两个参数要重新设置,否则模型输出的点数就跟你的标签对不上。
分类头不直接用全局池化,而是把 feature map 按高度方向切分成多个横向条带,每个条带内部做一次全局平均池化,再输入到一个小的全连接分类器。这样做的好处是保留了垂直方向的位置信息,模型可以区分“车道线在图像上半部分还是下半部分”。
3.3 损失函数:分类损失加辅助分割损失
损失函数这块,官方实现是分类损失和分割损失的加权和。分类损失用的是 CrossEntropyLoss,针对每个 row 上每个 lane 的列位置做分类;分割分支用的是带 OHEM(在线难例挖掘)的交叉熵损失,只对难样本计算梯度。
具体做的时候,Train 模式下的总 loss 是这样算的:
loss = ce_loss + seg_loss_weight * seg_lossseg_loss_weight在配置文件中通常设为 1.0。但我在实测中发现,如果数据集中车道线特别细、分割标签比较稀疏,这个权重可以适当调低到 0.5,能让主干分类结果更稳定一些。这个根据实际任务调整就行,不是死的。
另外留意一下,官方代码里在计算分类 loss 时,会忽略ignore_index对应的位置。这个 index 在配置里通常是 -2,对应的是标注中缺失的车道线点。所以数据预处理时千万不要把缺失点随便填成 0,否则模型会把“无车道线”也当作一个类别来学。
3.4 数据加载与标签生成
数据加载的逻辑在dataset.py里,核心工作是把车道线的坐标点转换成分类标签。流程大概是:
- 读取图像和标注点。
- 把图像缩放到模型输入尺寸,比如 800x320 或者 512x256。
- 对每条车道线,根据配置的
num_row数量,将图像在高度方向均匀分成若干行,找到每个行对应的 x 坐标。 - 把 x 坐标映射到
0 ~ num_col-1的整数标签。
这个标签映射过程是整个预处理里最容易出错的环节。源码里有一个generate_row_col_labels函数,只要你传入的标注格式正确,它就能自动生成标签。需要注意,如果一条车道线在某一行没有对应的 x 坐标(比如超出了图像边界),标签就填ignore_index,训练时会被忽略。
4. 完整复现实操:从训练到测试的一线记录
4.1 准备工作清单
开始之前,确保以下东西都备齐了:
- 单张 NVIDIA 显卡,显存至少 8GB,推荐 11GB 以上(我用的是 1080Ti 11GB 跑的 CULane)。
- 下载好的数据集,TuSimple 大约 4GB,CULane 大约 60GB,注意 CULane 解压后会更大。
- 在项目根目录新建
data文件夹,将数据集软链接进去。 - 如果需要预训练模型做 fine-tune,可以下载官方的 ResNet34 预训练权重,放到
pretrained文件夹里。
这里我要特别强调一个容易栽跟头的点:官方代码里默认从 ImageNet 预训练权重开始训练,但这个权重文件位置是在model/resnet.py里写死的。如果你不提前下载好对应权重并放到指定位置,训练启动时会报RuntimeError: Could not load pretrained model,虽然代码里会提示自动下载,但国内网络环境下很容易卡住。
4.2 训练启动与关键参数解释
训练命令很简单:
python train.py --dataset tusimple --config configs/tusimple.py--dataset参数有两个可选值:culane和tusimple。--config指定对应的配置文件。启动后,代码会先打印配置信息,然后是模型参数量统计,接着进入训练循环。
core 几个关键参数我列个表说一下:
| 参数 | 默认值 | 说明 |
|---|---|---|
epochs | 50 | 训练轮数,CULane 上 50 轮差不多能收敛到论文水平 |
batch_size | 32 | 显存不够就降到 16 或 8 |
lr | 0.01 | 初始学习率,如果数据集很小建议降到 0.001 |
optimizer | SGD | 官方用的 SGD + momentum 0.9 |
scheduler | 多步衰减 | 一般在 30、45 轮时衰减 0.1 |
input_width | 800 | 在配置文件中定义 |
input_height | 320 | 在配置文件中定义 |
训练过程中,最值得关注的 loss 走势是分类 loss。正常情况下前几个 batch 的 loss 会从 4 到 5 快速下降到 2 左右,然后慢慢降到 1 以下。如果用 TuSimple 数据集,50 轮大概要跑 3 到 4 个小时;CULane 数据量大,50 轮在 1080Ti 上大概要 12 个小时以上,做好心理准备。
4.3 验证与测试
训练完成后,用test.py在测试集上看效果:
python test.py --dataset tusimple --config configs/tusimple.py --models_dir checkpoint --model_name model_50.pthtest.py会把预测结果保存成 JSON 或者 png 文件,然后交给评估脚本计算指标。注意,官方评估脚本和测试脚本是分开的,test.py只负责生成结果,真正算 F1、AP、Accuracy 这些指标的是evaluate/culane.py或者evaluate/tusimple_evaluate.py。
如果你在测试阶段报错说找不到预测结果文件,多半是test.py内部保存路径跟评估脚本没对上。建议先跑通单张图片的可视化,确保模型输出正常,再跑全量测试。
4.4 可视化输出与结果解读
官方没有直接提供可视化脚本,但我自己写了一个简单的逻辑,把模型输出的 row-col 标签还原成坐标点,然后画到原图上。大概思路是:
- 把每个 row 上预测的 anchor 索引转换成 x 坐标。
- 用配置里的
row_anchor数组还原 y 坐标。 - 在图上用
cv2.polylines画线。
可视化的时候你会发现,模型输出的点列不一定完全平滑,这是正常的,因为它是逐行分类产生的离散结果。如果线条抖动比较厉害,可以做一个均值滤波或者用三次样条拟合一下,视觉上会好很多。我在实际项目中就加了一个 5 点滑动平均,效果立竿见影。
5. 常见问题与排查技巧实录
场景一:训练一开始就报 Shape mismatch
这个报错九成是预训练权重和模型结构不匹配。常见原因有两个:一是 PyTorch 版本不同导致 BN 层的num_batches_tracked字段对不上,二是配置文件里backbone类型跟权重不对应。解决办法很简单:重新下载对应版本的预训练权重,或者把配置文件里的backbone改成和权重一致的模型。
场景二:loss 为 NaN
出现 NaN 最常见的原因是学习率太大,尤其是 batch_size 比较小的时候。我实测发现,批量大小降到 8 以后,0.01 的学习率很容易起飞,建议按 batch_size 比例把学习率调低:学习率除以 4 或者 8。另外一个原因是数据里的标签越界,比如 x 坐标超出了num_col-1的范围,导致 loss 计算时索引越界,进而出 NaN。
场景三:CULane 评估脚本跑不起来
CULane 的官方评估工具是用 C++ 写的,需要编译。代码仓库虽然带了源码和 Makefile,但路径经常对不上。我的做法是直接用 Python 版的evaluate/lane.py,把预测的 lane 点序列转成 txt 格式,然后调用官方工具的外部接口。这块是整个复现过程中最折腾的,如果不想跟 C++ 编译死磕,可以直接用官方测试脚本生成的 txt 结果,再配合evaluate/culane.py里的 Python 版评估逻辑,跑出来的数据也有参考价值。
4. 显存不足怎么办
如果显存只有 6GB,建议把batch_size降到 8,同时把输入分辨率从 800x320 降到 640x256。注意,修改输入分辨率后,num_row和num_col也要跟着调整,否则标签映射会不匹配。具体关系是:num_row等于你切分的横向条带数,num_col等于横向 anchor 数,论文里推荐的行列数取决于输入尺寸,按比例缩放即可。
常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 训练 loss 不降 | 数据集格式错误、学习率太大 | 检查标注坐标是否对齐,调低学习率 |
| 验证精度为 0 | 评估脚本路径问题 | 确认预测结果和全局配置匹配 |
| 图片上有乱线 | 后处理没做平滑 | 增加均值滤波或者样条拟合 |
| 测试时显存溢出 | 输入尺寸太大 | 调低 batch_size 或者输入分辨率 |
| 多 GPU 报错 | batch_size 分配不均 | 使用单卡,或者修改DistributedDataParallel配置 |
6. 调优与二次开发:从复现到落地
6.1 超参数调整方向
如果你跑通了复现,接下来想在自己数据上提精度,我建议优先调整这几个方向:
- num_row 和 num_col:这两个值控制输出粒度。num_row 越大,纵向坐标越密集,但计算量也线性增长。在弯道多的场景,适当加大 num_row 能明显减少车道线拐弯处的毛刺。
- backbone 选择:追求速度就换 ResNet18 或者 MobileNetV3-Small,追求精度上 ResNet50。实测 CULane 上 ResNet34 到 ResNet50 的 F1 提升大约 0.5 个点,但推理速度下降了将近一半。
- 数据增强:官方默认的增强比较基础,只有随机翻转和颜色扰动。在复杂天气场景建议加上随机亮度和对比度调整,对鲁棒性帮助很大。
- row_anchor 配置:这个参数非常关键,它定义了在图像哪些 y 坐标处做分类预测。默认配置是针对 CULane 图像尺寸设计的,如果换成车载相机画面,一定要重新标定 row_anchor,否则模型输出的纵坐标位置会整体偏移。
6.2 自定义数据集上的迁移
迁移到自定义数据集的流程其实不复杂,核心就是:
- 把标注转成数据集格式(CULane 的 txt 格式相对简单)。
- 修改配置文件中的
dataset路径、input_width、input_height、num_row、num_col。 - 修改
data/dataset.py中的类别数(通常从 4 类车道线变成你自己的车道线类别数)。 - 加载官方预训练权重时,分类头部分会维度不匹配,需要手动把最后一层重新初始化。
我在实际项目里遇到过的情况是:摄像头安装位置和 CULane 数据集差异很大,导致直接迁移效果很差。后来我把标注重新做了一遍,并且根据相机的俯视角度重新计算了 row_anchor,模型才真正可用。这一步不要偷懒,标准视角下的模型不能直接套到俯视视角上。
6.3 推理加速与部署思路
跑通之后,如果你想往工程化方向走,有几个简单但有效的发力点:
- 计算图优化:训练时用的辅助分割 branch 在推理时完全不需要,只要导出模型时不包含就行。
- 半精度推理:用 PyTorch 的
half()或者 ONNX Runtime 开启 FP16,在支持 Tensor Core 的显卡上能获得将近 2 倍加速。 - TensorRT 部署:把 ONNX 模型转成 TensorRT engine,配合端侧 GPU 可以达到几十毫秒以内推理一帧。整个转换流程不复杂,但要特别注意 UFLD 模型里的
gather和argmax操作在 TensorRT 中的兼容性,我遇到过一次算子不支持的情况,最后是通过把 argmax 换成topk解决的。 - 去掉后处理:模型输出的 row 位置点天生就是排序后的,不需要像分割那种找连通域的操作,直接连接起来画线就完事,这给部署省了很大功夫。
6.4 我踩过的几个坑,提前帮你避开
复盘整个复现过程,最让我头疼的不是模型结构,而是一些看起来很低级的细节。第一个是 CULane 数据集的软链接问题,官方代码默认从data/CULane读数据,但我的数据盘挂在别处,用软链接之后,评估脚本里又有一个路径拼接写死了相对路径,折腾了半天。第二个是num_col调小到 50 以下时,车道线细的弯道会明显“断线”,因为列方向的量化太粗了,这个坑让我一度以为是模型收敛出了问题。第三个是训练和评估时用的图像尺寸必须完全一致,哪怕差一个像素,行分类的标签都会错位,精度直接掉到个位数。
如果你也准备复现这个项目,我的建议是:先用 TuSimple 数据集跑通全流程,因为这个数据集小,标注格式简单,肉眼验证效果很直观。等整条链路跑顺了,再换到 CULane 上做大规模训练。千万不要一上来就啃 CULane 的 60GB 数据和 C++ 评估工具,容易把自己劝退。
最后再分享一个实用小技巧:训练完模型之后,别急着只看 F1 指标,找几张有代表性的图(比如弯道、遮挡、雨天)单独可视化一下。很多时候指标差不多,但实际视觉效果差异很大,尤其是车道线边缘的连续性和弯道处的贴合度,这些才是落地时真正影响驾驶体验的细节。这个项目给了我一个很大的启发:很多看似只能靠分割解决的问题,换一个建模思路,可能效率和精度都能兼顾。