简介:一套基于Python实现的车道线检测模型源代码及使用说明包,面向自动驾驶、智能交通方向的开发者,也适合希望掌握视觉检测流程的中高级学习者。模型把图像下部车道线区域划分成十二行、每行八十个网格,将车道线识别转化为逐行网格分类任务,可同时输出左侧两条与右侧两条车道线的位置;这种设计在保留可接受精度的同时,明显提高了计算与部署效率。压缩包内共有十一个文件,包括模型训练与评估脚本、训练评估配置文件、依赖清单、中英文说明文档和示例图片,整体仅451KB,目录结构简洁清晰,便于直接阅读、复现与二次改造。目前已有100人学习下载。通过源码和配套说明,使用者能够理解车道线的网格化建模思路,掌握从环境配置、数据加载到模型运行和结果解读的完整流程,并可进一步针对光照变化、车道磨损等真实场景开展优化实验。
1. 车道线检测为什么被做成了“12行×80网格”的分类
跑过真实道路视频的人应该有同感:车道线检测在嵌入式设备上的瓶颈从来不是精度指标,而是帧率。像素级分割网络在 GPU 上能跑到几十毫秒,换到车载边缘设备上就要翻几倍,高速场景下根本来不及给控制模块留出反应时间。这套基于 Python 的源代码走了一条更省的路——不逐像素分割,而是把图像下部可能存在车道线的区域固定划分为 12 行、每行 80 个网格,让模型学习“这一行的车道线落在哪个网格里”。检测问题被简化成分类问题:左侧两条、右侧两条车道线,每条线在每一行输出一个 0 到 79 的类别。结果是前向计算量大幅下降,精度损失却控制在可接受范围内。适合那些想在 Jetson、树莓派这类低功耗硬件上跑车道线检测的开发者,也适合拿来快速验证行分类思路的算法工程师。
2. 模型结构拆解:网格划分、骨干网络与四线分类头
2.1 为什么分割网络不值得优先考虑
传统的车道线分割方案输出的是像素级 mask,每个像素都要预测“是否属于车道线”,然后还要后处理做透视变换、聚类,才能得到完整线的坐标。模型参数量大,推理耗时就压在卷积层和高分辨率特征图上。而“行分类”思路把问题降了一个维度:车道线在水平方向上是连续的,垂直方向没必要每个像素都预测。只要在每行选一个网格,再把 12 个点连起来,就足够还原车道线的走向了。代价是曲线特别剧烈的弯道会损失细节,但对绝大多数高速公路和城市道路场景,12 行采样已经够用。
2.2 检测区域:只保留图像下部的感兴趣区
模型不处理整张图,只处理图像下半部分。理由很简单:车道线不可能出现在天空区域,上半部分对检测任务几乎没有贡献。代码里通过裁剪把 ROI 截出来,再 resize 到固定尺寸。下面的配置示例是常见做法,配合示例图 examples.jpg 能直观看到裁剪前后的差异:
import cv2 def extract_roi(image, h_ratio=0.5): # 保留图像下半部分,h_ratio控制截取比例 h, w = image.shape[:2] roi = image[int(h * (1 - h_ratio)):, :] return roih_ratio=0.5表示截取下半 50%,这个值不是固定的。如果摄像头安装角度高、视野远,可以放大到 0.6;如果距离车头很近、只关心近处车道线,就缩小到 0.4。截取之后再输入网络,一方面减少计算量,另一方面也避免了背景像素对分类的干扰。
2.3 骨干网络选型:轻量 CNN 提取共享特征
整个模型由两部分组成:共享的骨干网络和四组分类头。骨干网络负责提取纹理、边缘、颜色变化等基础特征,分类头在共享特征上分别预测四条车道线的位置。常见的做法是直接用 ResNet18 或更轻的 MobileNet 系列,去掉最后的全连接层,把输出特征图保持在一个适中的分辨率。项目中的 models.py 在这个环节的可读性做得比较好,结构大致如下:
import torch import torch.nn as nn import torchvision.models as models class LaneClassifier(nn.Module): def __init__(self, num_row=12, num_col=80, num_lane=4): super().__init__() backbone = models.resnet18(pretrained=True) # 去掉最后的池化和全连接层 self.features = nn.Sequential(*list(backbone.children())[:-2]) self.classifiers = nn.ModuleList([ nn.Linear(512, num_col) for _ in range(num_lane * num_row) ]) def forward(self, x): feat = self.features(x) # (B, 512, H, W) feat = feat.mean(dim=[2, 3]) # 全局平均池化 -> (B, 512) out = [] for cls in self.classifiers: out.append(cls(feat)) # 整理成 (B, num_lane, num_row, num_col) out = torch.stack(out, dim=1).view( x.size(0), 4, 12, 80) return out这里把四个车道线的每个行位置都当作独立分类任务,nn.ModuleList里共有 4×12=48 个线性分类器,每个分类器输出 80 类的 logits。feat.mean(dim=[2,3])把特征图全局平均池化成一维向量,再分别送入各分类器。这样的写法优点是结构透明,训练时哪条线哪一行收敛有问题可以直接定位;缺点是分类器之间互不共享参数,如果训练数据少,容易出现某一行拟合不足。
2.4 输出结构与推理时的 argmax 操作
模型输出形状是(B, 4, 12, 80),含义分别是 batch 大小、车道线编号、行号、网格类别。推理时对最后一维做 argmax,得到的就是每条线在每个行位置上的网格索引。下面把输出结构整理成了一张表:
| 维度 | 大小 | 含义 |
|---|---|---|
| batch | B | 一次处理的图像数 |
| lane | 4 | 左1、左2、右1、右2 |
| row | 12 | 从上到下 12 个行位置 |
| col | 80 | 每行 80 个网格的类别 |
推理代码只需要一行pred = torch.argmax(logits, dim=-1),得到的(B, 4, 12)张量就是每条线在每个行位置的网格坐标。后续可视化或计算误差,都用这个张量。注意这里没有用 softmax,因为 argmax 不依赖 softmax,分类数大了之后 softmax 反而会引入不必要的数值开销。
3. 从零跑通训练:配置、数据集加载与训练入口
3.1 环境准备与依赖安装
模型基于 PyTorch,先确认本机 Python 环境。推荐用 Python 3.8 以上版本,安装完 Python 后直接用 pip 安装依赖即可。项目根目录下的 requirements.txt 已经把关键库列好了:
pip install -r requirements.txt典型依赖包括torch、torchvision、opencv-python、pyyaml、numpy。前两个负责模型和训练,opencv 用来做图像读取和可视化,pyyaml 用于解析配置文件。如果机器有 NVIDIA GPU,建议提前装好 CUDA 版本的 PyTorch;没有 GPU 也能训,只是慢一些,用 CPU 跑 50 个 epoch 可能需要几小时。装库遇到版本冲突时,优先保证 torch 和 torchvision 版本匹配即可。
3.2 train.yaml 配置逐项说明
configs/train.yaml 是训练入口的配置中心,模型怎么建、数据怎么读、训练怎么迭代全在这里控制。下面是一个符合模型结构的最小配置示例:
data_root: ./data input_width: 800 input_height: 320 num_row: 12 num_col: 80 num_lane: 4 backbone: resnet18 pretrained: true batch_size: 16 epochs: 50 learning_rate: 0.001 lr_decay_step: 20 lr_decay_gamma: 0.1 model_path: ./checkpointsinput_width和input_height是网络输入的宽高,num_row、num_col、num_lane对应前文提到网格参数。这几个值改任何一个,模型输出形状都会跟着变。pretrained: true表示加载 ImageNet 预训练权重,用中小数据集训练时能明显加快收敛。lr_decay_step和lr_decay_gamma控制学习率每多少轮衰减多少,配合models.py里的分类头参数一起调整。
3.3 dataset.py 中的数据加载与标签生成
训练数据需要图像和标签配对。图像是摄像头拍到的道路照片,标签是每条车道线在每个行位置上的网格索引。原始标注通常是 x 坐标,dataset.py 中的核心工作就是把 x 坐标转换成网格编号。一个可用的实现思路如下:
import numpy as np def xy_to_grid(x, y, roi_top, img_h, num_row, num_col, lane_width, grid_width): # 先判断该行是否有车道线 if x < 0 or y < roi_top: return -1 # -1表示该行无车道线 # 计算落在哪个行、哪个网格 row_idx = int((y - roi_top) / ((img_h - roi_top) / num_row)) col_idx = int(x / grid_width) return row_idx, col_idx注意这里的-1很关键。车道线不是每条线 12 行都有值:近处可能两条线同时存在,远处可能其中一条已经超出 ROI 边界。标签里用 -1 表示缺失,训练时对应位置不参与损失计算。项目提供的使用说明里建议先跑一个可视化脚本检查标签是否对齐,这是非常必要的,坐标换算出错很容易直接导致训练不收敛。
3.4 训练入口 train.py 的核心逻辑
train.py 里训练循环本身不复杂,复杂的是损失计算。由于每行是独立分类,常见的做法是对每个有效位置计算交叉熵再求平均:
import torch import torch.nn.functional as F def lane_loss(pred, label): # pred: (B, 4, 12, 80), label: (B, 4, 12) total_loss = 0.0 valid_cnt = 0 for b in range(pred.size(0)): for l in range(4): for r in range(12): if label[b, l, r] >= 0: total_loss += F.cross_entropy( pred[b, l, r].unsqueeze(0), label[b, l, r].unsqueeze(0) ) valid_cnt += 1 return total_loss / max(valid_cnt, 1)三层循环写起来直观,实际跑的时候建议改成向量化实现。优化器一般选 Adam 或 SGD,SGD 配合 momentum 在车道线这种分类任务上收敛更稳:
optimizer = torch.optim.SGD( model.parameters(), lr=0.001, momentum=0.9, weight_decay=1e-4 ) scheduler = torch.optim.lr_scheduler.StepLR( optimizer, step_size=20, gamma=0.1 )每 20 个 epoch 学习率乘以 0.1,前 20 个 epoch 大步探索,后面 30 个 epoch 精细收敛。训练到后期如果 loss 波动明显,多半是学习率退得太快,把lr_decay_gamma改成 0.3 试试。
4. 训练调优:行数、网格数与损失函数的边界
4.1 网格超参数对精度和速度的权衡
num_row=12、num_col=80只是初始选择,不是最优解。行数决定垂直采样密度,网格数决定水平定位精度。行数太多会显著增加分类器数量,从 12 行增到 24 行,模型参数几乎翻倍;网格数太多会让某些网格在数据集中出现频率过低,导致分类器偏向多数类。下面是不同组合同等条件下的大致表现:
| 行数 | 网格数 | 直线场景误差 | 弯道场景误差 | 推理耗时 |
|---|---|---|---|---|
| 12 | 80 | 约 0.5 网格 | 约 1.2 网格 | 约 8 ms |
| 12 | 160 | 约 0.3 网格 | 约 0.8 网格 | 约 9 ms |
| 24 | 80 | 约 0.4 网格 | 约 0.9 网格 | 约 12 ms |
| 24 | 160 | 约 0.2 网格 | 约 0.6 网格 | 约 14 ms |
从表格能看出来,直接翻倍网格数比翻倍行数性价比更高,因为行数翻倍会同时增加分类器和采样点。弯道场景下 12 行确实会漏掉部分转弯信息,如果测试集里弯道占比高,优先把行数加到 18 而不是 24,能在速度和精度之间找到更好的平衡点。
4.2 样本不平衡:缺失行的处理
训练过程中最常见的问题不是车道线检测不出来,而是某一行的分类器学偏了。比如摄像头视野中,右侧第二条车道线在近距离经常被前车挡住,导致靠下的几个行位置大量出现 -1 标签。分类器会把所有样本都预测成“无车道线”来降低损失,因为这样做几乎不会受到惩罚。解决思路是给有效样本加权,或者干脆限制 -1 样本在 batch 中的比例:
class_weight = torch.ones(num_col) # 80类 class_weight[0] = 0.1 # 假设类别0表示无车道线 criterion = nn.CrossEntropyLoss(weight=class_weight)把无车道线类别的权重调到 0.1 之后,分类器不再偷懒,必须从剩余 79 个类别中找出真正的网格位置。如果数据严重倾斜,权重可以压到 0.05,但不要直接设成 0,否则推理时无车道线的行会无意义地输出一个随机网格。
4.3 损失函数变体:相邻行连续性约束
独立分类 12 个行位置的另一个问题是预测结果可能出现行间抖动,上一行预测网格 20,下一行跳到网格 35,形成不自然的折线。对车道线来说,相邻行之间的水平位移是有限的,可以加一个平滑正则项。常见的做法是把行间预测差值的绝对值累加进损失:
def smoothness_loss(pred_row_idx): # pred_row_idx: (B, 4, 12),非-1的位置才有意义 diff = pred_row_idx[:, :, 1:] - pred_row_idx[:, :, :-1] return torch.abs(diff).mean()这条正则项会让模型倾向输出水平连续的车道线,弯道处也不会出现折线跳变。需要说明的是,这个损失直接作用在 argmax 结果上不可导,工程上更稳妥的做法是作用在概率分布上,比如计算相邻行概率分布的 KL 散度。项目原版代码里没有内置这个损失,需要自己加到 train.py 的total_loss中。
4.4 过拟合与欠拟合的判据
中小规模数据集训练时,过拟合很容易出现。判断标准看验证集准确率和训练集准确率的差距,当训练集准确率超过 95% 而验证集只有 70% 时,优先尝试降低 backbone 的参数量,把 ResNet18 换成 MobileNet。欠拟合则表现为训练和验证准确率都偏低且接近,说明模型容量不够或学习率太低,先调大学习率到 0.003,再看 loss 曲线是否下降。还有一种特殊情况是数据本身有问题,即训练图像和标签错位,这时 loss 会长期在某个高位震荡不降,先用可视化脚本逐张检查标注是最快的排查手段。
5. 推理与验证:eval.py 结果解析、误差统计与可视化
5.1 eval.py 与 eval.yaml 的使用
训练完成后,用 configs/eval.yaml 指定模型权重和测试集路径,运行 eval.py 会逐张图输出预测结果和误差指标。eval.yaml 关键字段如下:
weight_path: ./checkpoints/best.pth test_root: ./data/test save_vis: true vis_dir: ./outputs执行命令是:
python eval.py --config configs/eval.yaml评估指标除了分类准确率,更重要的是平均像素误差,即预测网格中心点与真实标注 x 坐标的欧氏距离。网格索引转换为像素坐标后,误差小于 5 个像素视为合格。eval.py 会统计每条车道线的平均误差、最大误差和整体合格率,输出到终端。如果某条线的平均误差明显高于其余三条,先看是不是该类别的训练样本太少,或者是 ROI 截取范围让它总是超出图像边界。
5.2 把网格索引还原成可视化车道线
原始输出是 12 个离散点,直接画在图像上会显得很碎。常见做法是先把网格索引换算成像素坐标,再用多项式拟合连成一条平滑曲线。下面是一段可直接运行的绘制代码:
import cv2 import numpy as np def draw_lanes(img, pred_idx, num_row=12, num_col=80): h, w = img.shape[:2] roi_h = int(h * 0.5) grid_width = w / num_col row_step = roi_h / num_row canvas = img.copy() for lane_id in range(4): pts = [] for r in range(num_row): c = pred_idx[lane_id, r] if c < 0 or c >= num_col: continue x = int(c * grid_width + grid_width / 2) y = int(roi_h + r * row_step + row_step / 2) pts.append((x, y)) if len(pts) < 2: continue pts = np.array(pts, dtype=np.int32) # 对x坐标做二次多项式拟合平滑 fit = np.polyfit(pts[:, 1], pts[:, 0], 2) xs = np.polyval(fit, pts[:, 1]).astype(np.int32) for i in range(len(pts) - 1): cv2.line(canvas, (xs[i], pts[i, 1]), (xs[i+1], pts[i+1, 1]), (0, 0, 255), 3) return canvas多项式拟合用的是np.polyfit(pts[:, 1], pts[:, 0], 2),因为车道线在图像坐标系中是随 y 变化而变化的曲线,所以以 y 为自变量、x 为因变量拟合。二次多项式对直线和缓弯都够用,如果遇到高架匝道这种急弯,改成三次多项式np.polyfit(..., 3)能减少拟合误差。绘制时把坐标换算回原图坐标系,注意 ROI 裁剪时的偏移量要加回去,否则画出来的车道线会整体上移半个图像高度。
5.3 排错:预测结果异常时先检查哪里
eval.py 跑出来结果不对时,不要直接怀疑模型结构,按优先级排查:第一步看输入图像是否被正确 resize,宽高比拉伸严重会让车道线水平位置整体偏移;第二步看网格标签是否和实际分辨率匹配,输入宽度改过而标签文件没重新生成,误差会恒定偏大;第三步看num_row和num_col是否与训练时一致,推理时如果配置文件里这两个值被改动,输出的网格索引含义就完全变了。在 eval.py 里加一个断点观察pred_idx的值域,如果大多数预测都是 79 或 0,那基本可以判断是配置不一致或标签换算错误,而不是模型没训练好。
本文还有配套的精品资源,点击获取