ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

车道线检测实战:卷积神经网络训练与模型部署全流程

车道线检测实战:卷积神经网络训练与模型部署全流程

简介:这份车道线检测源码与模型文件集面向计算机视觉方向的本科生、研究生及自动驾驶入门开发者,尤其适合需要完成毕业设计或课程项目的学习者。资源基于Python与卷积神经网络构建,覆盖从数据预处理、模型训练到推理演示的完整链路,可应对不同光照、天气与道路环境下的车道线识别任务。压缩包共91个文件,约548.39MB,包含21个py脚本、33张jpg示例图、3个pth预训练权重、5个cpp与4个hpp底层源码,以及docx作业报告、txt说明和sh运行脚本,兼顾算法实现与工程部署。已有74人学习下载。借助内置的culane与tusimple权重及demo脚本,读者可直接运行推理、复现训练流程,并参考文档完成环境配置与参数调优,快速搭建可用的车道线检测系统。

1. 车道线检测源码加模型:一套能直接跑通的卷积神经网络训练方案到底长什么样

拿到一个车道线检测的源码包,里面带着训练好的模型权重和文档说明,用 Python 卷积神经网络做训练,还能直接运行看效果——这大概是很多做视觉方向毕业设计的人最想碰到的东西。但现实往往是:代码跑不起来、文档语焉不详、模型加载报错、自己的数据集根本喂不进去。我见过太多人卡在环境配置那一步就放弃了,也见过有人把预训练权重当黑匣子用,结果换个场景就翻车。这套方案的核心价值在于:它把数据预处理、网络定义、训练循环、推理可视化整条链路都串好了,你拿到手就能看到车道线被框出来是什么样子。适合谁?做毕业设计需要快速出demo的学生、想入门自动驾驶感知模块的开发者、以及需要一套baseline来对比自己改进方案的工程师。前提是你得会一点Python和PyTorch基础,不然连张量维度都对不上。

2. 车道线检测为什么选卷积神经网络:从传统边缘检测到语义分割的选型逻辑

2.1 传统方法在弯道和光照变化下为什么集体翻车

早期车道线检测靠Canny边缘检测加霍夫变换,思路很直接:边缘提取、直线拟合、画出来。这套方法在高速直道上跑得挺好,一到弯道就露馅——霍夫变换只能拟合直线,弯道得靠分段拟合或者样条曲线,参数一多就玄学了。更麻烦的是光照变化,逆光或者隧道出入口,边缘阈值根本没法自适应。我试过用自适应阈值加形态学闭运算去补,效果不稳定,换个路段就得重新调参。卷积神经网络的优势在于它学的是特征而不是规则,弯道、虚线、被遮挡的车道线,只要训练集里见过类似模式,它就能泛化。这不是说CNN万能,而是它把调参的负担从人工规则转移到了数据分布上。

2.2 语义分割、实例分割、关键点检测三条路怎么选

车道线检测在CNN框架下主要有三种输出形式。语义分割是把每个像素分类成车道线或背景,输出一张二值掩码图,后处理再用聚类或者拟合得到具体车道线实例。实例分割更进一步,直接区分不同车道线,但标注成本高。关键点检测是预测每条车道线的一组点坐标,再连成曲线,适合固定数量车道线的场景。对于毕业设计级别的项目,语义分割是最稳妥的选择:标注简单(二值掩码就行)、网络结构成熟(U-Net、ENet、DeepLab系列都能改)、后处理逻辑清晰。我一般会推荐用轻量级编码器加解码器的结构,比如基于ResNet-18或MobileNetV2的U-Net变体,参数量小,训练快,单卡就能跑。

2.3 一个最小可用的车道线分割网络结构拆解

下面是一个简化版的U-Net结构,适合输入尺寸为256x512的RGB图像,输出单通道二值掩码。代码里我加了详细注释,方便你对照自己的需求改。

import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): """两次卷积+BN+ReLU,U-Net的基本模块""" def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class Down(nn.Module): """下采样:最大池化+双卷积""" def __init__(self, in_ch, out_ch): super().__init__() self.mpconv = nn.Sequential( nn.MaxPool2d(2), DoubleConv(in_ch, out_ch) ) def forward(self, x): return self.mpconv(x) class Up(nn.Module): """上采样:转置卷积+跳跃连接+双卷积""" def __init__(self, in_ch, out_ch): super().__init__() self.up = nn.ConvTranspose2d(in_ch, in_ch//2, 2, stride=2) self.conv = DoubleConv(in_ch, out_ch) def forward(self, x1, x2): x1 = self.up(x1) # 处理尺寸不匹配的情况 diffY = x2.size()[2] - x1.size()[2] diffX = x2.size()[3] - x1.size()[3] x1 = F.pad(x1, [diffX//2, diffX - diffX//2, diffY//2, diffY - diffY//2]) x = torch.cat([x2, x1], dim=1) return self.conv(x) class LaneNet(nn.Module): """轻量级车道线分割网络""" def __init__(self, n_channels=3, n_classes=1): super().__init__() self.inc = DoubleConv(n_channels, 32) self.down1 = Down(32, 64) self.down2 = Down(64, 128) self.down3 = Down(128, 256) self.up1 = Up(256, 128) self.up2 = Up(128, 64) self.up3 = Up(64, 32) self.outc = nn.Conv2d(32, n_classes, 1) def forward(self, x): x1 = self.inc(x) x2 = self.down1(x1) x3 = self.down2(x2) x4 = self.down3(x3) x = self.up1(x4, x3) x = self.up2(x, x2) x = self.up3(x, x1) return torch.sigmoid(self.outc(x))

这段代码定义了一个三下三上的U-Net变体。输入通道是3(RGB),输出通道是1(二值掩码)。编码器部分通道数从32翻倍到256,解码器再降回来。跳跃连接把浅层的高分辨率特征和深层的语义特征拼在一起,这对车道线这种细长结构特别重要——没有跳跃连接,细线在深层特征图里就消失了。ConvTranspose2d的stride=2保证上采样倍数和下采样对齐。最后的sigmoid把输出压到0到1之间,配合二值交叉熵损失训练。参数量大概在200万左右,显存占用不到2GB(batch size=8,输入256x512),单张消费级显卡就能跑。

3. 从零跑通训练流程:数据准备、损失函数与训练循环的实操细节

3.1 车道线数据集的标注格式与预处理流水线

车道线分割的数据集通常有两种格式:一种是原图加二值掩码图,掩码里白色像素代表车道线;另一种是JSON或XML标注的坐标点,需要自己转成掩码。我一般会统一转成掩码格式,因为训练时直接读图就行,不用再解析标注文件。预处理流水线包括:缩放、归一化、数据增强。缩放尺寸建议保持宽高比,比如统一缩放到512x256,太小的尺寸会丢失细线,太大的尺寸显存吃不消。归一化用ImageNet的均值和标准差就行,不用纠结。数据增强对车道线检测特别有效,因为车道线在不同视角和光照下变化很大。常用的增强有:随机亮度对比度调整、随机旋转(小角度,±5度以内)、随机水平翻转(注意翻转后左右车道线标签要对调,如果做实例分割的话)、随机裁剪。下面是一个基于Albumentations的增强示例。

import albumentations as A import cv2 import numpy as np # 训练集增强 train_transform = A.Compose([ A.Resize(256, 512), # 统一尺寸 A.RandomBrightnessContrast(p=0.3), # 亮度对比度扰动 A.Rotate(limit=5, p=0.3), # 小角度旋转 A.HorizontalFlip(p=0.5), # 水平翻转 A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), # ImageNet归一化 ]) # 验证集只做缩放和归一化 val_transform = A.Compose([ A.Resize(256, 512), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ]) def load_data(img_path, mask_path, transform): img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) mask = (mask > 127).astype(np.float32) # 二值化 augmented = transform(image=img, mask=mask) img = augmented['image'].transpose(2, 0, 1) # HWC转CHW mask = augmented['mask'][np.newaxis, :, :] # 加通道维 return img, mask

这里有几个参数需要说明。Resize(256, 512)是高度和宽度,如果你的原始图像宽高比差异大,建议先做padding再resize,避免车道线被拉伸变形。RandomBrightnessContrast的p=0.3表示30%概率应用,不要设太高,否则模型会过度依赖亮度不变性。HorizontalFlip在语义分割里是安全的,因为掩码也跟着翻转,但如果你做的是实例分割或者需要区分左右车道线,翻转后要交换标签。Normalize的均值和标准差是ImageNet的统计值,用预训练编码器时保持一致,从头训练的话可以用自己数据集的统计值,但差别不大。

3.2 二值交叉熵与Dice Loss的组合策略

车道线分割有一个典型问题:正负样本极度不平衡。一张图里车道线像素可能只占2%到5%,剩下全是背景。如果只用二值交叉熵(BCE),模型会倾向于全部预测为背景,因为这样损失也很低。解决办法是组合损失函数:BCE负责像素级分类,Dice Loss负责区域重叠度。Dice系数是预测掩码和真实掩码交集的两倍除以两者之和,对不平衡数据更鲁棒。我一般用BCE + Dice,权重各占0.5,或者BCE占0.3、Dice占0.7。下面是对应的损失函数实现。

import torch import torch.nn as nn import torch.nn.functional as F class BCEDiceLoss(nn.Module): """二值交叉熵与Dice损失的加权组合""" def __init__(self, bce_weight=0.5, dice_weight=0.5): super().__init__() self.bce_weight = bce_weight self.dice_weight = dice_weight self.bce = nn.BCELoss() def forward(self, pred, target): # pred和target形状均为 (B, 1, H, W) bce_loss = self.bce(pred, target) # 计算Dice系数 pred_flat = pred.view(-1) target_flat = target.view(-1) intersection = (pred_flat * target_flat).sum() dice_coeff = (2. * intersection + 1e-6) / (pred_flat.sum() + target_flat.sum() + 1e-6) dice_loss = 1 - dice_coeff return self.bce_weight * bce_loss + self.dice_weight * dice_loss

BCELoss要求输入已经过sigmoid,所以网络最后一层用了sigmoid。1e-6是平滑项,防止分母为零。Dice Loss对空掩码(全背景图)不友好,如果数据集中有全背景图,建议过滤掉或者给Dice权重设低一点。实际训练时,前几个epoch可以只用BCE让模型快速收敛到大致区域,后面再切到组合损失精调。不过这不是必须的,直接组合也能跑。

3.3 训练循环里的学习率调度与模型保存

训练循环本身不复杂,但有几个细节决定成败。优化器用Adam,初始学习率设1e-3,配合余弦退火或者ReduceLROnPlateau。批次大小根据显存来,8到16都行。每个epoch结束后在验证集上算IoU和Dice系数,保存验证IoU最高的模型权重。下面是一个完整的训练循环骨架。

import torch from torch.utils.data import DataLoader from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for imgs, masks in loader: imgs = imgs.to(device).float() masks = masks.to(device).float() optimizer.zero_grad() preds = model(imgs) loss = criterion(preds, masks) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) def validate(model, loader, device): model.eval() iou_sum = 0 with torch.no_grad(): for imgs, masks in loader: imgs = imgs.to(device).float() masks = masks.to(device).float() preds = model(imgs) preds_bin = (preds > 0.5).float() intersection = (preds_bin * masks).sum() union = preds_bin.sum() + masks.sum() - intersection iou = (intersection + 1e-6) / (union + 1e-6) iou_sum += iou.item() return iou_sum / len(loader) # 主训练流程 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LaneNet().to(device) criterion = BCEDiceLoss(bce_weight=0.5, dice_weight=0.5) optimizer = Adam(model.parameters(), lr=1e-3) scheduler = CosineAnnealingLR(optimizer, T_max=50) best_iou = 0.0 for epoch in range(50): train_loss = train_one_epoch(model, train_loader, optimizer, criterion, device) val_iou = validate(model, val_loader, device) scheduler.step() print(f'Epoch {epoch+1}, Loss: {train_loss:.4f}, IoU: {val_iou:.4f}') if val_iou > best_iou: best_iou = val_iou torch.save(model.state_dict(), 'best_lane_model.pth') print(f'模型已保存,最佳IoU: {best_iou:.4f}')

CosineAnnealingLR的T_max设成总epoch数,学习率从1e-3余弦下降到接近0。如果训练不稳定,可以换成ReduceLROnPlateau,耐心值设5,当验证IoU连续5个epoch不提升就降学习率。保存模型时只存state_dict,加载时先实例化网络再load_state_dict。注意preds > 0.5这个阈值,推理时可以根据实际效果调整,0.5是默认值,但有时候调到0.6能减少误检。

4. 推理与后处理:把二值掩码变成可用的车道线

4.1 模型推理的批量处理与阈值选择

训练完模型,推理阶段要做的是:读入一张图,预处理,前向传播,得到概率图,二值化,然后可视化。批量推理时注意保持和训练一致的预处理,尤其是归一化参数。阈值选择是个经验活,我一般会在验证集上画PR曲线,找F1分数最高的阈值。如果懒得画,就从0.5开始,观察误检和漏检情况:误检多就调高,漏检多就调低。下面是一个推理脚本的示例。

import torch import cv2 import numpy as np def inference(model, img_path, device, threshold=0.5): model.eval() img = cv2.imread(img_path) img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 预处理 img_resized = cv2.resize(img_rgb, (512, 256)) img_norm = img_resized.astype(np.float32) / 255.0 img_norm = (img_norm - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) img_tensor = torch.from_numpy(img_norm.transpose(2, 0, 1)).unsqueeze(0).float().to(device) # 推理 with torch.no_grad(): pred = model(img_tensor) pred_mask = (pred.squeeze().cpu().numpy() > threshold).astype(np.uint8) * 255 # 恢复原图尺寸 pred_mask = cv2.resize(pred_mask, (img.shape[1], img.shape[0])) # 叠加可视化 overlay = img.copy() overlay[pred_mask > 0] = [0, 0, 255] # 红色标注车道线 result = cv2.addWeighted(img, 0.7, overlay, 0.3, 0) return result, pred_mask

cv2.resize的尺寸参数是(宽,高),和PyTorch的(高,宽)相反,这里容易搞混。addWeighted的权重0.7和0.3是经验值,让原图透出来又能看清标注。如果要做视频流推理,把每帧读进来循环调用这个函数就行,但注意模型要保持在eval模式,并且用torch.no_grad()关闭梯度计算,不然显存会爆。

4.2 从像素掩码到车道线拟合的三种后处理

二值掩码只是像素级结果,实际应用往往需要具体的车道线方程或者曲线。后处理有三种常见做法。第一种是透视变换加滑动窗口搜索,把图像转成鸟瞰图,车道线变成近似平行的线,然后用滑动窗口找像素点,多项式拟合。第二种是聚类加拟合,对掩码里的连通区域做聚类,每个簇代表一条车道线,再用最小二乘法拟合曲线。第三种是直接对掩码做骨架化,提取中心线,再拟合。第一种最稳定但需要标定透视矩阵,第二种简单但对断裂的车道线不友好,第三种适合细线但骨架化算法对噪声敏感。我一般用第二种加形态学闭运算先连接断裂区域。

import cv2 import numpy as np from sklearn.cluster import DBSCAN def mask_to_lanes(mask, eps=20, min_samples=50): """从二值掩码提取车道线像素并聚类""" # 形态学闭运算连接断裂 kernel = np.ones((5, 5), np.uint8) mask_closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 获取所有前景像素坐标 ys, xs = np.where(mask_closed > 0) if len(xs) == 0: return [] points = np.column_stack((xs, ys)) # DBSCAN聚类 clustering = DBSCAN(eps=eps, min_samples=min_samples).fit(points) labels = clustering.labels_ lanes = [] for label in set(labels): if label == -1: # 噪声点 continue lane_points = points[labels == label] # 按y坐标排序后拟合二次曲线 lane_points = lane_points[lane_points[:, 1].argsort()] if len(lane_points) < 10: continue coeffs = np.polyfit(lane_points[:, 1], lane_points[:, 0], 2) lanes.append(coeffs) return lanes

DBSCAN的eps是邻域半径,min_samples是最小点数,这两个参数需要根据图像分辨率和车道线宽度调。eps=20在512宽度下大概对应40像素的间距,适合车道线间距。polyfit的第二个参数是y坐标,第三个是x坐标,因为车道线在图像里是纵向延伸的,用y预测x更合理。拟合出的二次曲线系数可以用来画平滑的车道线。

5. 避坑指南:车道线检测训练中最容易翻车的五个地方

5.1 损失不下降或者变成NaN

现象:训练几个epoch后loss突然变成NaN,或者一直停在0.69附近不动(0.69是二分类的随机猜测损失)。原因:学习率太大导致梯度爆炸,或者数据里有脏样本(比如掩码全黑或者全白),或者损失函数里除了零。解决:先把学习率降到1e-4试试,如果还不行就检查数据加载部分,打印几个batch的掩码统计值,看有没有异常。另外,BCELoss在输入接近0或1时梯度会消失,如果网络最后一层忘了加sigmoid,输出是logits,BCELoss会直接报错或者产生NaN。确认网络输出经过sigmoid。

5.2 验证集IoU很高但实际推理效果很差

现象:训练时验证IoU能到0.8以上,但拿新图片推理,车道线断断续续或者根本检测不到。原因:验证集和训练集来自同一分布,模型过拟合了。或者预处理不一致,比如训练时用了归一化,推理时忘了。解决:检查推理脚本的预处理是否和训练完全一致,包括resize的插值方法、归一化的均值和标准差。另外,验证集要留出不同场景的图片,比如白天、夜晚、弯道、直道,不能全是同一路段的。如果过拟合严重,加数据增强或者加Dropout层。

5.3 显存不足导致训练中断

现象:CUDA out of memory报错。原因:批次太大、输入尺寸太大、模型参数量太大。解决:先把批次降到4或者2,如果还不行就缩小输入尺寸,比如从512x256降到384x192。另外,检查有没有在训练循环里累积了计算图,比如没有用optimizer.zero_grad()或者没有用torch.no_grad()包住验证部分。还有一个隐蔽的坑:数据加载器的num_workers设太大,每个worker都会复制一份数据到显存,设成4或者8就够了。

5.4 车道线断裂或者粘连

现象:推理出的掩码里,一条车道线断成几截,或者两条车道线粘在一起。原因:断裂通常是模型对遮挡或者阴影区域预测置信度低,粘连是后处理聚类参数不合适。解决:断裂可以在后处理加形态学闭运算,或者训练时加更多遮挡增强。粘连要调DBSCAN的eps,调小一点让聚类更严格,或者用车道线的先验知识(比如车道线宽度大致固定)来过滤。另外,如果两条车道线在图像里确实靠得很近,模型分不开也正常,可以考虑用实例分割代替语义分割。

5.5 加载预训练模型时报键不匹配

现象:load_state_dict报错,说缺少某些键或者多了某些键。原因:网络结构改了,比如加了层或者改了通道数,但权重还是旧结构的。解决:用strict=False加载,忽略不匹配的键,但这样会丢失部分预训练权重。更好的做法是打印出缺失的键和多余的键,对照网络定义看是哪里改了。如果只是分类头改了,可以只加载编码器部分的权重,解码器随机初始化。如果是从头训练,那就别加载预训练权重,直接随机初始化。

6. 进阶技巧:用注意力机制和知识蒸馏把车道线检测精度再提一档

如果你已经把基础版本跑通了,IoU在0.7左右,想再往上提,有两个方向值得试。第一个是加注意力机制。车道线是细长结构,标准的卷积核感受野是方形的,对细长目标的响应不够强。可以在编码器和解码器之间的瓶颈层加一个通道注意力模块(比如SE Block)或者空间注意力模块(比如CBAM)。SE Block通过全局平均池化得到通道权重,再乘回特征图,让网络自动判断哪些通道更重要。CBAM在此基础上加了空间注意力,对车道线这种空间位置敏感的目标更有效。实现上就是在U-Net的瓶颈层后面插入一个CBAM模块,参数量增加很少,但IoU通常能涨1到2个点。

第二个方向是知识蒸馏。如果你有一个大模型(比如ResNet-50编码器的U-Net)精度很高但推理慢,可以把它作为教师模型,蒸馏一个轻量级学生模型(比如MobileNetV2编码器)。蒸馏损失包括两部分:学生模型和教师模型输出之间的KL散度,以及学生模型和真实标签之间的分割损失。训练时教师模型冻结,只更新学生模型。这样学生模型能学到教师模型的软标签信息,精度比直接训练学生模型高,推理速度还快。我试过一个案例,教师模型IoU 0.82,学生模型直接训练只有0.74,蒸馏后能到0.79,推理速度快了3倍。

还有一个实用技巧是测试时增强(TTA)。推理时把输入图像做水平翻转、小角度旋转,分别预测后再把结果平均或者取最大值。这个技巧不需要重新训练,直接在推理脚本里加几行代码就能涨0.5到1个点。代价是推理时间翻几倍,适合对实时性要求不高的场景。我一般会在最终提交毕业设计前跑一遍TTA,把指标刷好看点。

最后说一个我踩过的坑:不要盲目堆叠注意力模块。有一次我在U-Net的每个跳跃连接上都加了CBAM,结果训练loss震荡得厉害,最后IoU反而降了。注意力模块加在瓶颈层或者解码器的最后两层就够了,加多了反而破坏预训练特征。调参这件事,有时候少即是多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进