
简介这套基于语义分割的车道线检测Python项目面向计算机视觉学习者、毕设与课程设计学生以及自动驾驶相关研发人员。方案以GCN、ERFNet等语义分割模型为核心覆盖图像预处理、车道线区域划分、效果可视化完整流程源码结构清晰且注释详尽便于对照理解技术原理。压缩包共32个文件包含8个Python源码脚本、多组模型检查点meta/data/index、4张阈值效果对比图、bat一键构建脚本及README项目说明整体大小仅1.35MB。核心模块分别负责数据准备、模型构建、训练与推理测试并附多阶段模型权重文件支持分步复现训练与结果验证。项目说明文档详述了研究背景、技术原理、实验结果及优化方向配合测试数据可完成模型训练与性能评估。目前已有65人学习浏览适合用于课程设计、毕业设计或进一步研究语义分割在车道线检测中的实际应用。1. 语义分割做车道线检测从灰度图阈值到像素级分类的落地转变车道线检测是智能驾驶和车载影像方案里最基础也最磨人的一块。早几年大家习惯用边缘检测加霍夫变换先提边缘再拟合直线效果在晴天还好一到树影、隧道口、雨天反光就现原形。基于语义分割方法的车道线检测python源码把这个问题换了个思路不再去找线而是把每个像素分类成车道线或非车道线本质是一个逐像素的图像分类任务。这种方法的优势在于它天然能处理车道线被车辆遮挡、磨损变淡、光照突变的情况模型学到的是上下文语义而不是边缘的几何特征。这套源码解决的实际问题很明确给出一份能直接跑通训练和推理的Python工程输入一张前视道路图输出是掩码图和多条车道线的拟合结果。比较适合正在做车道线检测毕业设计、需要快速出结果对比实验的学生也想从传统图像处理切到深度学习的嵌入式或自动驾驶从业者。我刚拿到这类工程时第一反应是先看数据接口和后处理训练流程反而是其次——因为很多开源项目代码能跑但换成自己的数据就废了。2. 语义分割车道线的数据与标签为什么别人项目跑不通的根源在标注格式2.1 车道线数据集对比Tusimple、CULane 和 Cityscapes 的标签格式差异车道线语义分割最常用的三个公开数据集是Tusimple、CULane和Cityscapes它们的数据组织方式完全不同直接决定了你的数据加载代码怎么写。Tusimple的标注是JSON格式每条车道线由一个点数组表示每个点给的是归一化坐标它没有像素级掩码——这意味着用Tusimple训练语义分割网络必须先做一次坐标到掩码的转换。CULane则直接提供像素级标注图车道线是白色像素、背景是黑色省了一步转换但它的图分辨率高、场景多为高速路和城市道路的泛化差异很明显。Cityscapes是为街景理解设计的标注质量好但车道线标签只覆盖主车道区域对侧车道线标注不完整用它训练需要裁剪和过滤。这三个数据集在输入尺寸上也差异很大。Tusimple原始图是1280x720CULane是1640x590Cityscapes是2048x1024。大多数人训练时统一缩放到512x256或者640x360这会导致车道线的粗细发生非线性变化——细线缩小时可能断成若干段放大时又变成几条平行线。所以做数据加载时要特别注意缩放插值算法在OpenCV里用INTER_NEAREST处理掩码用INTER_LINEAR处理原图。如果你拿到这份源码发现训练过程中loss不降或剧烈震荡首先要排查标签转换逻辑是否把车道线宽度渲染错了。2.2 把Tusimple折线标注转成语义分割掩码的参考实现Tusimple的标注格式如下方代码中json_line所示要转成训练用的掩码图核心是遍历每条线、用cv2.polylines把点序列连接成线。这个逻辑写起来很短但有两个关键点一是线的宽度要按原图和缩放后尺寸的比例换算固定为5个像素在缩放到512宽时往往细成一条缝不利于模型学习二是数据增强时掩码必须和原图做完全相同的变换这接口稍后解释。下面的代码是参考实现直接放进你的源码工程的data_loader里就能用。python import json import cv2 import numpy as npdef tusimple_json_to_mask(json_path, img_shape(720, 1280), line_width5): 将Tusimple数据集的折线标注转换成单通道掩码图 img_shape 是原图高、宽line_width 控制渲染宽度 mask np.zeros(img_shape, dtypenp.uint8) with open(json_path, r) as f: annotation json.load(f)for lane in annotation[lanes]: # lanes 里每个元素是一组 x 坐标y 坐标统一存在 h_samples 里 xs np.array(lane) ys np.array(annotation[h_samples]) # 过滤掉 x 为 -2 的无效点 valid xs 0 if valid.sum() 2: continue pts np.stack([xs[valid], ys[valid]], axis1).astype(np.int32) cv2.polylines(mask, [pts], isClosedFalse, color255, thicknessline_width) return mask使用示例如果你后续要缩放到 512x256建议 line_width 按比例缩小mask tusimple_json_to_mask(label.json, img_shape(720, 1280), line_width3)这段代码的逻辑说明每个lane对应一组x坐标它和h_samples里的y坐标一一配对x为-2表示这个点在画面内没有对应位置先过滤掉。polylines的isClosed参数必须设False否则首尾相连会形成一条封闭多边形把路面中间都填成白色。参数调整方面line_width在720p原图下建议5到8像素如果缩放到512宽建议取3左右否则整条线占像素比例过大模型容易把路面裂缝也学成车道线。还有一个容易忽略的环节掩码最终要转成one-hot编码类别是0背景、1车道线、2车道边界如果你的源码里类别数和这里不一致训练时会报shape不匹配。2.3 数据加载代码里的同步增强陷阱语义分割的数据增强和图像分类不同原图和掩码必须用同一套随机参数变换。最常见的是随机水平翻转和随机旋转很多初学者分别处理原图和掩码导致训练时模型看到的是错位的监督信号loss怎么调都降不下去。这块我一般会统一封装一个transform函数输入原图和掩码输出增强后的成对数据。这里有一段最小实现python import random import cv2 import numpy as npdef paired_transform(image, mask, crop_size(512, 256)): 原图和掩码同步随机裁剪、水平翻转和亮度扰动 image: BGR图, mask: 单通道掩码(0/255) h, w image.shape[:2] # 1. 随机裁剪注意掩码用最近邻插值 top random.randint(0, max(0, h - crop_size[1])) left random.randint(0, max(0, w - crop_size[0])) image image[top:topcrop_size[1], left:leftcrop_size[0]] mask mask[top:topcrop_size[1], left:leftcrop_size[0]]# 2. 随机水平翻转必须同方向 if random.random() 0.5: image cv2.flip(image, 1) mask cv2.flip(mask, 1) # 3. 亮度扰动只作用于图像掩码不变 if random.random() 0.5: alpha 0.8 0.4 * random.random() image cv2.convertScaleAbs(image, alphaalpha, beta0) return image, mask这里的关键参数是crop_sizeTusimple原尺寸是1280x720但直接整图输入显存吃紧裁剪成512x256可以保证每张图里至少有一条完整车道线。裁剪时如果random.randint越界程序不会报错但会返回空图所以加一条max(0,h-crop_size[1])的约束。亮度扰动时切记掩码不要跟着乘alpha否则标签值直接变成0到255之间的灰阶交叉熵会算得一团糟。如果你要加色彩抖动、高斯噪声这类增强也只对原图做。3. 从源码选型看语义分割网络U-Net、ERFNet与轻量化部署的真实差距3.1 为什么车道线检测常用编码器解码器结构而不是DeepLabV3这类大模型车道线语义分割对空间分辨率要求特别高因为车道线本身只有几像素宽低分辨率的特征图上一旦丢失细节上采样回来根本恢复不了。DeepLabV3这种用空洞卷积扩大感受野的方案在PASCAL VOC这类分割任务上表现好但它下采样倍数大恢复细节全靠decoder的shortcut车道线这种极细目标往往会断掉。U-Net家族的Skip Connection结构天然适合这个场景——encoder每下采样一次decoder的对应层就拼接一次让浅层的边缘信息直接流向输出层。ERFNet是另一种常见选择它基于残差连接和因子化卷积参数量只有U-Net的几分之一推理速度更快适合在Jetson这样的嵌入式板上实时跑。源码里用的方案大多是U-Net或ERFNet之一。如果你对训练速度不敏感、显存充裕U-Net是稳妥选择结构直观效果可预期如果要上实车或者嵌入式设备ERFNet是更务实的方向。两者的第二个差别在输出层U-Net通常输出一个与输入同分辨率的概率图ERFNet有的变体会输出1/8分辨率再上采样这意味着推理时要多做一次双线性插值。你拿到源码后第一件事应该是看模型最后一层的输出尺寸和损失函数里如何计算pred和mask的shape匹配很多报错都发生在这一步。3.2 车道线分割的损失函数设计类别极度不均衡是头号杀手车道线在整张图里的像素占比通常在1%以下这意味着如果直接用普通的交叉熵损失模型只要把所有像素预测成背景就能得到99%以上的准确率训练几乎学不到东西。常见的解决思路是加权交叉熵或者Dice Loss。加权交叉熵给车道线类别加一个权重系数比如背景权重1.0、车道线权重10实现简单但对权重的设置敏感。Dice Loss按预测和标签的重叠度算梯度不直接依赖像素比例对小目标更友好但在训练初期梯度不稳定容易震荡。我见过一份源码里同时把两种损失加起来用的用lambda控制比例这对新手来说不如先用单一的Dice Loss稳定。训练语义分割网络还有一个隐藏的坑输出层的激活函数和损失函数的匹配。如果你在最后一层用了softmax然后配合torch.nn.CrossEntropyLoss网络会做两次softmax梯度计算是对的但数值范围受影响收敛变慢。正确做法是让模型输出logits即不经过softmax的原始分数CrossEntropyLoss内部会做softmax计算。如果你自己实现了Dice Loss就要手动先做softmax或者sigmoid因为Dice Loss期望输入是概率值而不是logits。这里给一个基于PyTorch的常用组合python import torch import torch.nn as nn import torch.nn.functional as Fclass DiceLoss(nn.Module): definit(self, smooth1.0, class_weightNone): super().init() self.smooth smooth self.class_weight class_weightdef forward(self, logits, targets): # logits: (N, C, H, W), targets: (N, H, W) 类型为 long probs F.softmax(logits, dim1) # 转概率 n, c, h, w probs.shape # one-hot 编码 targets one_hot F.one_hot(targets, num_classesc).permute(0, 3, 1, 2).float() dims (2, 3) # 在H、W维度计算Dice numerator (probs * one_hot).sum(dimdims) denominator probs.sum(dimdims) one_hot.sum(dimdims) self.smooth dice_score 2.0 * numerator / denominator loss 1.0 - dice_score.mean() return loss组合损失示例lambda 建议 0.5~1.0ce_loss nn.CrossEntropyLoss()dice_loss DiceLoss()total_loss ce_loss(logits, mask) 0.8 * dice_loss(logits, mask)这段代码里的smooth参数是Dice Loss的平滑项防止除零。class_weight参数写成None实际使用时可以传一个tensor比如torch.tensor([0.1, 1.0, 0.8])给不同语义类不同的权重。这里有个调参细节Dice Loss在训练初期loss值很高接近1不要慌这是它把预测和真值重叠度拉高的自然过程等几十个迭代后loss会快速下降。如果你发现loss下降后出现周期性尖峰大概率是学习率太大把初始学习率调小一个数量级试试。3.3 训练配置参数参考优化器、学习率与批次大小的搭配习惯车道线语义分割训练过程里优化器和学习率的选择比网络结构更影响最终效果。我常用AdamW替代Adam加上weight_decay可以抑制过拟合学习率初始设在1e-3到5e-4之间配合余弦退火调度器。批次大小受显存约束常见设置在8到16之间但注意批次太小会导致BatchNorm统计量不稳定如果只能用batch size为4建议换用GroupNorm或实例归一化或者直接冻结BN层的momentum参数。下面的训练配置是一份可以直接用于迁移的模板python import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLRoptimizer optim.AdamW(model.parameters(), lr5e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max300, eta_min1e-6)训练循环关键示例for epoch in range(300): model.train() for images, masks in train_loader: images, masks images.cuda(), masks.cuda() optimizer.zero_grad() logits model(images) loss dice_loss(logits, masks) loss.backward() # 梯度裁剪防止细线目标导致梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step()T_max设为300匹配总epoch数eta_min是学习率下限控制在1e-6防止后期剧烈抖动。梯度裁剪max_norm5.0是很多分割任务的经验值尤其在使用Dice Loss时刚开始梯度常达到几十不裁剪直接loss变成NaN。如果你发现训练到一半时验证集的准确性不再提升先看学习率是否已经退火到很低值附近再决定要不要提高weight_decay到1e-3尝试更强正则。需要注意如果你的显卡只有11GB显存batch size设为8配512x256输入基本是上限再大就会CUDA Out Of Memory这时代码里的pin_memoryTrue和num_workers4设置能让数据读取不再成为瓶颈。4. 推理阶段的后处理从语义掩码到车道线二次多项式拟合4.1 为什么模型输出和可视化效果之间还差多个处理环节很多人在跑通源码后发现输出的掩码图上车道线是有了但没法直接用于控制或者展示——语义分割的原始输出是像素级类别而实际工程要的是每条车道线的位置参数。这个差距就是后处理要补的。常见流程是先从分割结果里提取车道线像素再按行扫描每个高度位置上响应值最大的点最后做二次多项式拟合。这本质上是把像素集合压缩成曲线方程让下游模块能直接消费。如果跳过这个步骤像素级输出在车辆控制里几乎没有使用价值而可视化时也会出现掩码边缘散乱、车道线粗细分叉的难看效果。滑动窗口搜索是车道线后处理的经典做法。从左下角开始把图像按行分成若干水平条带在每个条带里统计车道线像素的x坐标分布取响应值最高的位置作为该行的车道中心点然后依次向上扫。这里有个容易掉进去的坑如果中间有一条车道的像素因为破损出现空缺滑窗会跟丢需要通过上一帧的拟合结果来约束搜索范围。后面的代码实现里我会把搜索范围约束在上一行车道线x坐标附近的正负值窗口里有效避免跳线现象。4.2 多项式拟合车道线的完整实现与参数选择推理阶段我把整个后处理流程封装成一个独立的lane_postprocess.py文件输入是分割网络的输出概率图输出是每条车道线的多项式系数和可视化点集。下面是核心代码包含按行扫描、二次多项式拟合、以及最小二乘求解三个步骤python import cv2 import numpy as npdef extract_lane_points(prob_map, thresh0.5, scan_height10): 从分割概率图中提取车道线像素点 prob_map: 网络输出经过softmax后的背景/车道线概率, shape(H,W) thresh: 概率阈值, 低于此值的像素丢弃 scan_height: 每隔多少行采样一次 h, w prob_map.shape lane_mask (prob_map thresh).astype(np.uint8) * 255 points [] # 从下往上隔行扫描 for row in range(h - 1, 0, -scan_height): row_data lane_mask[row, :] # 找这一行所有车道线像素 col_idx np.where(row_data 0)[0] if col_idx.size 0: continue # 可能存在多条车道线按连续区间分段 split_pos np.where(np.diff(col_idx) 30)[0] segments np.split(col_idx, split_pos 1) for seg in segments: if seg.size 3: continue x_center int(np.mean(seg)) points.append((x_center, row)) return np.array(points, dtypenp.int32)def fit_lane_polyline(points, degree2): 最小二乘拟合, 返回多项式系数 使用 np.polyfit 但需要作异常点剔除 if points.shape[0] 3: return None x points[:, 0].astype(np.float32) y points[:, 1].astype(np.float32) coeffs np.polyfit(y, x, degree) # 注意: 以y为自变量, x为因变量 return coeffs推理示例prob_map model_output[0, 1] # 取车道线这个类别的概率图pts extract_lane_points(prob_map, thresh0.4, scan_height5)if pts is not None:coeffs fit_lane_polyline(pts)# 生成可视化点: y从0到图像高度ys np.linspace(0, prob_map.shape[0] - 1, 50)xs np.polyval(coeffs, ys)这里有一个关键的坐标约定np.polyfit里自变量和因变量不能互换。你拟合的是每一行y上车道线中心的x坐标所以传入顺序是(y, x)输出系数也按y的幂排列。如果你写成polyfit(x, y)画出来会是一条竖向曲线完全不对。scan_height参数控制采样密度设太大拟合曲线会丢失细节设太小计算量大且容易出现零散噪声点。thresh阈值设0.4到0.5之间比较合理太高会把模糊车道线切光太低则噪声点大量混入。如果你的图像里有栏杆、路沿这类和车道线形状相似的物体还需要加一个ROI区域裁剪只让拟合算法在路面区域和车道线候选范围内计算。4.3 曲线拟合结果的可信度判断与可视化叠加拟合完多项式之后不能直接认为结果可靠需要做合理性校验。一个简单有效的做法是检查拟合残差拟合点与曲线之间的平均距离是否超过设定阈值。本实现里可以用如下代码计算拟合误差python def fit_error(coeffs, points): 返回每个点到拟合曲线的平均绝对误差 x points[:, 0].astype(np.float32) y points[:, 1].astype(np.float32) pred_x np.polyval(coeffs, y) return np.mean(np.abs(pred_x - x))过滤异常曲线误差超过15像素则丢弃err fit_error(coeffs, pts)if err 15:print(拟合曲线异常丢弃)阈值15像素是在512宽的输入图像下比较合理的范围如果缩放到更大分辨率要按比例放大。可视化时把拟合点画到原图上注意线的颜色要区分左右车道比如左车道蓝色右车道绿色。此外还有一条可以对照检查的经验真实车道线在图像中一般呈近似直线或缓曲率如果拟合出三次项系数非常大的曲线通常意味着搜索到了路沿、防撞栏或者前车边缘等噪声区域。此时可以通过计算曲率半径的方式判断合理性这在后面章节会展开。5. 车道线检测全景实践的五大踩坑标注、训练到推理的翻车现场5.1 掩码生成时车道线断裂polylines宽度和坐标类型导致细线被断开现象训练时TensorBoard里看到的预测掩码车道线都是断断续续的碎点而不是连续的线。原因第一种是坐标没转成int32float坐标传入polylines会被截断导致短线段错位第二种是line_width设成1像素在缩放后原图尺寸变小时本来的连续线在逐行采样时出现无像素的间隙。解决先把坐标astype(np.int32)然后把line_width至少设为3另外在polylines之前可以先画一条闭合的细线来兜底。还有就是确认数据加载代码里没有因为transpose导致行列颠倒。5.2 训练loss降到很低但分割结果是一团白类别标签和通道顺序错位现象验证图片里车道线区域全是白色的连路面也是白色的整个掩码图几乎等于全白。原因常见是标签使用0表示背景、1表示车道线但你用OpenCV读掩码时模式设成了cv2.IMREAD_GRAYSCALE导致本来单通道的掩码图被读成三通道同时每个通道数值不同。另一个常见原因是one-hot编码时使用argmax取反将背景类误认为是前景。解决读掩码后立即断言它的shape是(H, W)而不是(H, W, 3)然后强制转成uint8并做一次阈值处理把一切非0像素统一变成1排除灰度值不等于255的情况。5.3 换自己采集的数据集后效果骤降分辨率、光照分布和标注风格的迁移问题现象在Tusimple上训练到不错的效果换自己手机或行车记录仪拍的视频检测率直接掉一半。原因Tusimple的标注是固定摄像头视角和高速公路环境车道线清晰完整而自采数据里存在树荫、斜射光、路面破损、车道线磨损等情况分割模型的泛化能力被这些分布外场景击穿。解决不要直接拿公开数据集训练完就上场先做风格迁移增强比如随机调整亮度扰动范围、加入模拟阴影和噪声、做柱面坐标变换模拟不同摄像头视角。还有一个务实建议在自己的数据集上标注至少200张图做fine-tuning这比追求网络结构改动更见效。5.4 预测结果里车道线左右抖动的厉害时序信息缺失和拟合点过少现象单帧检测正常但视频序列中车道线位置在相邻帧之间来回跳拟合结果不稳定。原因逐帧独立推理没有利用时间上下文且拟合点数量太少导致参数波动大。解决在后处理中加入轻量级的卡尔曼滤波或EMA平滑。每个车道线的多项式系数可以用一阶低通滤波例如coeff_smooth 0.7 * coeff_prev 0.3 * coeff_new。这样做带来的代价是引入滞后车速快时曲线跟随不及时所以帧率必须稳定在25FPS以上否则平滑系数要调低到0.3左右。5.5 推理速度不够用浮点模型直接跑在边缘设备上卡成PPT现象Jetson Nano这类嵌入式板上用PyTorch模型做推理帧率只有个位数。原因没有做模型导出和int8量化浮点算子多而且后处理里频繁使用for循环遍历每个像素计算效率极低。解决先把模型导出为ONNX格式再用TensorRT做FP16或INT8量化这一步通常能带来3到5倍加速。后处理代码里把逐行for循环改成矩阵操作比如用np.argmax在整行上一次性找出车道线响应位置而不是每个点判断阈值。这一步改完推理帧率会有质的提升。注意导出ONNX时要把所有动态轴固定成训练时的尺寸否则TensorRT编译时shape不确定会直接报错。6. 进阶落地技巧曲率半径计算与车道偏离估计让分割结果真正用起来语义分割输出的掩码转成多项式系数后下一步就到真正车用层面了。曲率半径和偏离中心距离这两个指标是车道线检测结果能否用到辅助驾驶的直接判据。曲率半径需要把像素坐标系转到车辆坐标系常见做法是假设摄像头内参和外参固定用逆透视变换把图像平面上的点映射到地面平面上然后在地面平面坐标系里计算多项式曲率半径。逆透视变换的代码实现通常只有一步cv2.getPerspectiveTransform和cv2.warpPerspective难点在于选取变换前后的四个对应点。我常用的做法是选图像下方的梯形区域作为源点把梯形的两条边映射成目标图像中平行的两条竖线。变换后车道线在鸟瞰图里近似平行这时再做多项式拟合曲率半径的计算才是准确的。如果你不做逆透视直接算会得到带有透视畸变的错误数值。计算曲率半径的公式是R (1 (2Ay B)^2)^(3/2) / |2A|其中A和B是二次多项式x Ay^2 By C的系数注意这个A不是拟合输出里的第一个系数因为多项式是按幂排序的。下面的代码展示了从拟合系数到曲率半径的完整计算链路python def calc_curvature(coeffs, y_eval): 计算给定纵向位置 y_eval 处的曲率半径 coeffs 是 np.polyfit(y, x, 2) 的返回值, 即 [A, B, C] A, B, _ coeffs # 导数 dx/dy 2Ay B derivative 2 * A * y_eval B # 二阶导数 d2x/dy2 2A second_deriv 2 * A if abs(second_deriv) 1e-6: return float(inf) curvature (1 derivative**2) ** 1.5 / abs(second_deriv) return curvature计算车身偏离车道中心距离def lane_offset(left_coeffs, right_coeffs, y_eval360): 输入左右车道线的拟合系数计算车辆中心与车道中心的横向偏移 y_eval 通常取车辆前方近处的地平线位置 left_x np.polyval(left_coeffs, y_eval) right_x np.polyval(right_coeffs, y_eval) if right_x left_x: return None # 左右线交叉视为异常 lane_center_x (left_x right_x) / 2.0 # 假设图像几何中心就是车辆中心 image_center_x 255.0 offset image_center_x - lane_center_x # 正值表示车辆偏左负值偏右 return offset这里的y_eval要选在车头前方对应的图像位置一般在图像高度的60%到70%处参数用360表示在720p图像中靠下三分之一的位置。如果vehicle中心线和车道中心线的偏移持续超过50个像素可以判定车辆在偏离车道触发预警逻辑。这个方法在实际工程中比直接看掩码直观得多尤其适合快速验证模型的落地能力。我自己的习惯是在源码里保留一个debug模式把曲率半径、偏离偏移量直接打印在画面左上角。这个技巧帮我排掉过很多拟合异常的问题比如某帧曲线突变时数值会瞬间跳变到几千一眼看出是拟合点混入了噪声而不是车辆真的转弯。当你把这一整套从数据到后处理到指标计算的链路跑通时才会真正理解什么叫语义分割的工程落地——模型只是其中一环周围的数据接口和后处理才是决定用户体验的地方。希望这些经验能帮到你。本文还有配套的精品资源点击获取