简介:基于语义分割的车道线检测项目,完整提供Python源码与说明文档,面向计算机视觉、深度学习方向的毕设学生与工程师,帮助解决车道线精准识别与分割的落地实现问题。压缩包共32个文件,大小1.35MB,包含8个Python脚本(训练、测试、数据预处理、模型定义)、多个GCN/ERFNet模型权重文件(meta、index、data-00000-of-00001)、阈值分割效果对比图(PNG)以及README说明文档,结构清晰便于复现与二次开发。项目采用GCN、ERFNet等语义分割网络,并提供threshold_0.5/0.7/0.9可视化输出,方便观察阈值对检测结果的影响。目前已有65人学习下载,适合作为课程设计、毕业设计或车道线检测技术入门的参考资料。通过实际操作,读者可掌握数据准备、模型训练、权重加载与效果评估的完整流程,并结合源码注释与项目说明快速理解关键算法。
1. 为什么车道线检测最后都绕回语义分割:从“找线”到“理解画面”
车道线检测在车载视觉里是个挺有意思的异类:它看起来像目标检测,但车道线不是“物体”,没有固定宽高比,还经常被车头挡住一半;它看起来像传统图像处理,但光照、磨损、雨雪一变,边缘检测那套参数就崩。语义分割的思路是彻底换一个问法——不去“找线”,而是对每个像素做分类:这个像素属于左车道线、右车道线,还是背景。问题从“线在哪”变成“哪些像素是线”,模型学到的不是边缘特征,而是上下文语义,所以对遮挡、阴影、强光这类干扰要稳得多。
这个项目标题表达的正是这种做法:用语义分割模型做车道线检测,Python 源码加上项目说明一起打包。无论你拿它来跑 demo、改自己的数据集,还是把模型换掉做对比实验,核心要理解的是一整套数据标注到模型推理的流程,而不只是跑通一个 forward。适合的读者是已经会 Python 基础、想认真做一次语义分割落地的人,能跟着步骤把训练、评估、后处理串起来,也能看懂结果为什么好、为什么坏。下面按我自己的实践经验,把这个方案的选型、实现和踩坑点完整过一遍。
2. 把车道线检测拆成“逐像素三分类”:标签设计与数据集处理
2.1 为什么不是检测、不是分割实例,而是语义分割
先解决一个绕不开的疑问:YOLO 系列现在也有实例分割,为什么车道线这种场景还是语义分割更顺手?你可以对比一下三者的输出差异:目标检测输出的是框,但车道线是细长条,框的 IoU 对几条线并行的情况毫无区分能力;实例分割会把每条线拆成独立实例,但车道线经常断裂、重叠、被车辆遮挡,实例 ID 的分配本身就很难稳定;语义分割只回答“这个像素是不是车道线”,不关心是哪一条,输出稳定,后处理里再做聚类或拟合把线整理出来就行。
还有一个很现实的原因——标注成本。语义分割的标签只需要把车道线区域涂白、背景涂黑,一张图几分钟就能标完;实例分割要逐条线给 ID,断裂的车道线到底是同一条还是两条,标注员得反复对上下文。真实项目里,数据的迭代速度往往比模型结构更影响最终效果。
所以在网络结构上,这个方向常用的就是 U-Net 或者 DeepLabV3 这类语义分割模型,输入一张 RGB 图,输出和原图同尺寸的 mask,每个像素取 argmax 得到类别。具体到车道线任务,类别一般是三类:背景、左车道线、右车道线(如果只需要单条本车道线,那就两类)。分类数越少,模型越容易收敛,这也是为什么第一版 demo 不建议直接上多车道线分类。
2.2 数据集从哪来:TuSimple 格式解析与本地自定义数据集
开源的 TuSimple 车道线数据集是这个方向最常见的起点。它的标注是以 JSON 文件组织的,每张图片对应一个 json,里面有 lanes(每条线的 x/y 坐标点)、h_samples(固定的 y 坐标采样值)和 raw_file(图片路径)。读的时候要注意:lanes 里的每个元素是一条线的所有 x 坐标,长度和 h_samples 一致,值为 -2 表示该 y 位置没有采样点。
我一般会写一个转换脚本,把 TuSimple 的曲线标注转成语义分割的 mask 图,逻辑是:从 json 里取到每条线的点集,用 [cv2.polylines]( 画线,再cv2.fillPoly填充成有宽度的区域,最后按类别写入 mask。核心代码如下:
import json import cv2 import numpy as np import os def tusimple_to_mask(json_path, h_samples, img_shape, thickness=8): """ :param json_path: TuSimple 标注 json 路径 :param h_samples: TuSimple 固定的 y 坐标列表(从标注文件读取) :param img_shape: (H, W) 原始图片尺寸 :param thickness: 车道线填充宽度,单位像素,一般 6~10 :return: mask 单通道图,0=背景,1=左车道线,2=右车道线 """ with open(json_path, 'r', encoding='utf-8') as f: anno = json.load(f) mask = np.zeros((img_shape[0], img_shape[1]), dtype=np.uint8) lanes = anno['lanes'] for idx, lane_x in enumerate(lanes): pts = [] for x, y in zip(lane_x, h_samples): if x != -2: # -2 表示该 y 高度没有车道线采样 pts.append([int(x), int(y)]) if len(pts) < 2: continue pts = np.array([pts], dtype=np.int32) line_type = 1 if idx == 0 else 2 # 约定第 0 条为左车道线,其余为右 cv2.polylines(mask, pts, isClosed=False, color=line_type, thickness=thickness) return mask # 使用示例 h_samples = [i for i in range(0, 720, 10)] # 按实际 json 里的 h_samples 为准 mask = tusimple_to_mask("label.json", h_samples, (720, 1280)) cv2.imwrite("mask.png", mask * 60) # 乘系数是为了可视化时人眼可分辨逻辑说明:这段脚本先把每条车道的 x/y 点拼成点集,然后画线加粗到 8 个像素宽度。thickness 是一个直接影响分割效果的参数——太细(比如 2 像素),正负样本比例严重失衡,模型很容易把所有像素都预测成背景;太粗(比如 20 像素),评估时算 IoU 很虚高,真实感知距离误差也大。经验上 8 像素左右在 1280x720 分辨率下比较合理。
如果你没有开源数据集,想用自己的行车记录仪视频,流程是抽帧、清洗、用 LabelMe 或 CVAT 标注,导出的 JSON 转成 mask。注意标注时不要只涂线的中心骨架,要把车道线被磨损、被阴影遮住的边缘也标进去,否则模型会误以为“亮白色的才是车道线”,对阴影里的线直接漏检。这一步的图省事会在后面训练时加倍还回来。
3. 用 PyTorch 从零跑通 U-Net 车道线分割:模型搭建、训练与评估
3.1 选 U-Net 而不是 DeepLabV3+ 的原因:显存、收敛速度、上手难度
U-Net 在医学图像、遥感图像这些“小数据集 + 精细边界”的任务里表现一直很稳,车道线恰好符合这两个特征。DeepLabV3+ 用空洞卷积扩大感受野,对大目标更友好,但车道线本质上是细长结构,过大感受野反而会把远处模糊的线“平滑”掉。另一个实际原因是显存:DeepLabV3+ 的 ASPP 模块在 1280x720 分辨率上跑,batch size 稍微一调就爆显存;U-Net 的编码器-解码器结构更轻,配合半精度训练可以稳定跑到 batch size 8 以上。
U-Net 的核心是下采样四次的编码器(特征图从 3 通道变到 512 通道,分辨率缩到 1/16)和上采样对称的解码器,跳跃连接把同尺度的底层特征拼回来。这样设计的直接收益是:浅层信息(车道线的边缘、纹理)能一路传到输出层,不会在多层卷积里被稀释掉。对车道线这种“细节决定成败”的任务,Skip Connection 比什么都重要。
模型结构我直接引用一个精简的 U-Net,不依赖额外的模型库,方便你把代码拆开看每一步:
import torch import torch.nn as nn 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 UNet(nn.Module): def __init__(self, in_channels=3, num_classes=3): super().__init__() # 编码器(下采样路径) self.enc1 = DoubleConv(in_channels, 64) self.enc2 = DoubleConv(64, 128) self.enc3 = DoubleConv(128, 256) self.enc4 = DoubleConv(256, 512) self.pool = nn.MaxPool2d(2) # 瓶颈层 self.bottleneck = DoubleConv(512, 1024) # 解码器(上采样路径) self.up4 = nn.ConvTranspose2d(1024, 512, kernel_size=2, stride=2) self.dec4 = DoubleConv(1024, 512) self.up3 = nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2) self.dec3 = DoubleConv(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2) self.dec2 = DoubleConv(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2) self.dec1 = DoubleConv(128, 64) self.out = nn.Conv2d(64, num_classes, 1) def forward(self, x): # 编码 e1 = self.enc1(x) # 1/1 e2 = self.enc2(self.pool(e1)) # 1/2 e3 = self.enc3(self.pool(e2)) # 1/4 e4 = self.enc4(self.pool(e3)) # 1/8 # 瓶颈 b = self.bottleneck(self.pool(e4)) # 1/16 # 解码(每一步先上采样,再与对应编码器特征拼接) d4 = self.up4(b) d4 = torch.cat([d4, e4], dim=1) d4 = self.dec4(d4) d3 = self.up3(d4) d3 = torch.cat([d3, e3], dim=1) d3 = self.dec3(d3) d2 = self.up2(d3) d2 = torch.cat([d2, e2], dim=1) d2 = self.dec2(d2) d1 = self.up1(d2) d1 = torch.cat([d1, e1], dim=1) d1 = self.dec1(d1) return self.out(d1)逻辑说明:forward 里每一步上采样后都要torch.cat拼接编码器的同层输出,这是 U-Net 的骨架逻辑。ConvTranspose2d 做上采样会带来棋盘格伪影,但在这个任务里影响不明显,因为车道线的输出本身就是一条带状的掩码,不是精细的像素级纹理。如果你在意细节质量,可以换成双线性插值上采样加 3x3 卷积,代价是参数量增加。
参数说明:第一层通道数设为 64,对大分辨率输入来说这是显存和表达能力的折中。如果把 64 改成 32,显存能省一半但分割边界会明显变糊;改成 128,效果提升有限,训练时间却接近翻倍。对车道线这种二值特征明显的任务,64 起步就够了。
3.2 训练脚本:损失函数、数据增强与半精度
训练前必须处理一个硬骨头:类别不平衡。一张 1280x720 的图里,车道线像素占比通常在 2%~5% 之间,如果直接拿 CrossEntropyLoss 训练,模型会学到“全部预测为背景”,因为这样损失已经很低了。这也是为什么很多人跑公开源码第一轮 loss 下降很漂亮,但 mask 全黑——损失函数被背景类主导了。
解决方法是给前景类加权。PyTorch 的 CrossEntropyLoss 自带 weight 参数,按类别像素占比的反比设置即可。下面给出完整的训练脚本核心部分:
import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset from torch.cuda.amp import GradScaler, autocast import cv2 import numpy as np import os # ---------- 1. 自定义 Dataset ---------- class LaneDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=(384, 640)): self.img_paths = sorted([os.path.join(img_dir, f) for f in os.listdir(img_dir)]) self.mask_paths = sorted([os.path.join(mask_dir, f) for f in os.listdir(mask_dir)]) self.img_size = img_size def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = cv2.imread(self.img_paths[idx]) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (self.img_size[1], self.img_size[0])) mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) mask = cv2.resize(mask, (self.img_size[1], self.img_size[0]), interpolation=cv2.INTER_NEAREST) img = img.astype(np.float32) / 255.0 img = torch.from_numpy(img).permute(2, 0, 1).float() mask = torch.from_numpy(mask).long() return img, mask # ---------- 2. 加权损失 ---------- def compute_class_weights(dataset, num_classes=3): """统计每个类别的像素占比,返回权重(占比越小权重越大)""" counts = np.zeros(num_classes) for _, mask in dataset: for c in range(num_classes): counts[c] += (mask.numpy() == c).sum() total = counts.sum() weights = total / (counts * num_classes) # 归一化 return torch.tensor(weights, dtype=torch.float32) # ---------- 3. 训练配置 ---------- model = UNet(in_channels=3, num_classes=3).cuda() weights = compute_class_weights(train_dataset) criterion = nn.CrossEntropyLoss(weight=weights.cuda()) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) scaler = GradScaler() # 半精度训练用的梯度缩放器 for epoch in range(30): model.train() for img, mask in train_loader: img, mask = img.cuda(), mask.cuda() optimizer.zero_grad() with autocast(): # 半精度前向,显存直接砍半 logits = model(img) loss = criterion(logits, mask) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() scheduler.step()参数说明:img_size=(384, 640)是训练分辨率,原图 720p 直接训练显存吃紧,下采样到一半已经能保留车道线的连续结构。如果你想在 1080p 上训练,batch size 大概率得调到 2 以下,这时 BN 的统计量会变得不稳定,建议换成 GroupNorm。lr=1e-3配合 AdamW 在 ResNet 编码器上会略高,但 U-Net 这种从零训练的模型可以接受;如果用了 ImageNet 预训练编码器,要降到1e-4。
还有一个容易忽视的配置:mask 的 resize 必须用cv2.INTER_NEAREST。默认的双线性插值会把 0/1/2 三个类别插出小数,生成不存在的“混合类别”,训练出来的分割图边缘全是噪点。这个坑我见过太多次了。
3.3 评估指标:用 mIoU 和 Accuracy 为什么不够,加一个“漏检率”
语义分割的标准评估是 mIoU(每个类别的 IoU 取平均),但车道线任务不能只看 mIoU。原因是:mIoU 对“预测了但位置偏移几个像素”的情况扣分很小,而车道线的安全冗余要求极高——预测的车道线哪怕只偏了 10 个像素,在 50 米外对应的横向偏差已经超过半米,这是不能接受的。
所以我一般在评估脚本里同时算三个指标:mIoU(整体分割质量)、Pixel Accuracy(背景类主导,仅作参考)、车道线中心线偏差(把预测 mask 按列取质心,和 GT 质心比欧氏距离,超过 5 个像素记为漏检)。中心线偏差才是真正能反映“这条线能不能用”的指标。计算方式如下:
def centerline_distance(pred_mask, gt_mask, threshold=5.0): """ :param pred_mask: (H, W) 预测结果,像素值为 0/1/2 :param gt_mask: (H, W) 真实标签 :param threshold: 质心距离阈值,超过记为漏检 :return: 平均距离和漏检率 """ distances = [] miss_count = 0 total_rows = 0 for y in range(gt_mask.shape[0]): # 只评估 GT 存在车道线的行 gt_cols = np.where(gt_mask[y] == 1)[0] pred_cols = np.where(pred_mask[y] == 1)[0] if len(gt_cols) == 0: continue total_rows += 1 if len(pred_cols) == 0: miss_count += 1 continue gt_center = gt_cols.mean() pred_center = pred_cols.mean() dist = abs(gt_center - pred_center) distances.append(dist) if dist > threshold: miss_count += 1 avg_dist = np.mean(distances) if distances else 0 miss_rate = miss_count / total_rows if total_rows else 0 return avg_dist, miss_rate逻辑说明:按行比较做质心距离,比直接像素级 IoU 更能反映“线偏了没偏”。如果miss_rate超过 2%,说明模型在远处或者强光下的稳定性不够,这时候先去查数据的标注质量,再考虑调模型复杂度。注意这里只评估类别 1(左车道线),因为左右线在对称场景下表现接近,算一条线的偏差已经够判断模型健康度。
4. 把模型“调稳”而不是“调高”:训练车道线分割的 5 个关键参数
很多从目标检测转过来的人,训练语义分割时第一反应是堆 epoch、堆数据增强,但车道线这个任务有几个参数是决定性别的,顺序不对,跑多少轮都是白费。
第一个是 loss 的类别权重。前面提过,背景类占比可能超过 95%。这里给一个具体公式:权重 = 总像素数 / (该类别像素数 * 类别数)。用这个公式算出来的权重,前景类权重通常是背景类的 20~50 倍。如果 mIoU 里背景类高得离谱(比如 98% 以上)而前景类低于 50%,就是权重还不够狠,直接乘一个 2 再试。
第二个是数据增强策略。车道线对光照变化极敏感,但对翻转不敏感(左右翻转会改变左右逻辑,这是最大陷阱)。代码里如果用RandomHorizontalFlip,一定要把 mask 的左右类别互换,否则模型会学乱。我常用的是:随机亮度扰动(±30%)、随机高斯模糊(核大小 3~5)、随机透视变换(模拟车辆变道视角)。随机裁剪是毒药,因为裁剪会切断车道线连续性,模型会把断线学成特征。如果显存允许,宁可整图缩小也不要裁剪。
第三个是学习率调整策略。U-Net 用 CosineAnnealing 比 StepLR 稳定,尤其在 epoch 数 30~50 时。如果发现 loss 在中段震荡,把初始 lr 降到 3e-4,不用动 schedule。不要用 ReduceLROnPlateau,语义分割的 loss 曲线本身有噪声,plateau 判断会频繁误触发。
第四个是输入尺寸与 batch size 的平衡。显存不够时优先降 batch size 到 4,不要降分辨率到 320 以下。车道线在低分辨率下会断成碎点,模型输出“虚线感”特别重。如果 batch size 降到 2 还是爆显存,检查激活值内存峰值是否来自跳跃连接——U-Net 的 cat 层在 1280 分辨率下极吃显存,可以尝试在解码器前加一层nn.MaxPool2d降低拼接特征图分辨率。
第五个是类别数的选择。如果只想做本车道线检测,二分类(背景/车道线)比三分类稳定得多。三分类在左右车道线外观相似时容易互相穿越,后处理时需要额外聚类。源码给的是三分类,但你可以直接改num_classes=2,把 mask 里 2 改成 1,loss 权重重算一遍,效果通常会有可见提升。这个改动花费不到 10 分钟,值得作为第一件事尝试。
5. 车道线分割最常见问题排查:5 个我在实践中反复踩的坑
5.1 预测 mask 全黑,但 loss 在正常下降
- 现象:训练十几个 epoch 后,输出 mask 可视化全是 0(背景类),但训练 loss 曲线漂亮地下降。
- 原因:类别不平衡 + loss 权重没生效。多数情况是
CrossEntropyLoss的 weight 传参格式不对(用了 NumPy 数组而不是 Tensor),或者 mask 的标签从 1 开始编号导致类别 0 缺失。 - 解决:打印一个 batch 的 mask 唯一值,确认类别是 {0, 1, 2} 而不是 {1, 2, 3};再看 weight 是不是
torch.float32类型且挂在正确 device 上。
5.2 训练时 mIoU 一路涨,测试时同一张图预测效果差得离谱
- 现象:在验证集上 mIoU 有 85%+,但拿训练集里的一张原图走推理,分割结果惨不忍睹。
- 原因:绝大部分是预处理不一致。训练时做了标准化(归一化到 0~1),但推理脚本忘了做,或者 resize 的插值方式不一致(训练用区域插值,推理用线性)。另一个可能:训练时图片是从 BGR 转 RGB,推理时直接读了 BGR。
- 解决:把训练和推理的预处理封装成同一个函数,强制复用。血泪经验:不要相信“我复制过来了”这种口头保证,直接 import 同一份代码。
5.3 车道线预测出来是虚线,断开特别碎
- 现象:预测 mask 上车道线不连续,短线、断点密集,后处理拟合出来也是一截一截的。
- 原因:训练分辨率太低或标注厚度太细。还有一个容易忽略的:数据增强里的
RandomErasing或 Cutout 会把线挖断,模型学到的是“断着来也没关系”。 - 解决:先去掉所有遮挡类增强,把标注 thickness 从 4 提到 8,把训练分辨率提到 640。多数情况下这两个动作就能让线连续起来。
5.4 左右车道线粘连,语义分割输出一个大色块
- 现象:预测结果里类别 1 和类别 2 在图像中间区域连成一片,分不清左右。
- 原因:透视关系下,远处车道线在像素空间的间距很小,模型感受野覆盖到两条线的中间区域时,特征混淆。本质是三分类的决策边界在远处叠加了。
- 解决:后处理里加形态学腐蚀(核大小 3x3, 迭代 2 次)把粘连断开,再按连通域取最大两块,分别归类为左右线。同时检查标注——是不是标注图里左右线本身就粘连了?如果是,标注阶段用
cv2.subtract把重叠区域归给离自车更近的一侧。
5.5 强光下漏检,阴影里也漏检,但普通天气效果还行
- 现象:晴天直射、树荫遮挡场景下,车道线区域预测为背景,别的场景正常。
- 原因:训练集里缺少光照变化的样本,模型学到的是“像素亮=线”的捷径,而不是“结构特征=线”。
- 解决:给训练集做随机 gamma 变换(gamma 范围 0.5~1.8),并加入随机阴影块模拟树荫。这是数据层面最有效的动作,比换任何模型结构都管用。我一般对光照增强样本做在线增强,概率设到 0.5,不用离线扩充,省显存也省标注。
6. 从分割掩码到“能用的车道线”:后处理、可视化与验证技巧
模型输出的掩码离“能用的车道线”还差一步后处理。车辆控制模块要的不是一张图,而是车道线的曲线方程或者离散点。常见做法是:mask 按列扫描取每行的质心点,用 3 次多项式拟合。但有几个细节直接决定拟合质量。第一,靠近车头部分的线宽和远处不同,如果按整幅图均匀采样,近处的点权重过大,远处拟合会飘;我一般只取 mask 中下部 70% 的区域做拟合。第二,质心点要先做离群点剔除——比如某行质心与前后两行的质心横向差超过 20 个像素,直接丢弃,避免被误检的杂散点带偏。第三,拟合用最小二乘但加上 Huber 损失,对异常点的鲁棒性比纯 MSE 好得多。
可视化验证也有讲究。把预测掩码直接叠加在原图上,颜色失真不明显;但如果把 mask 转为鸟瞰图(IPM)再叠加,能看到车辆行进方向的清晰曲线形状,这才是判断模型是否真正理解“车道线是平行等宽”的最直观方法。我之前调参时只看原图叠加,结果远侧线略微外扩没发现,切到鸟瞰图一眼就看出两条线不平行,返回去调标注坐标校正参数,问题就解决了。建议在你的项目里也留一个visualize_birdview.py,把 10 张验证图的结果拼成网格,每次训练完扫一眼,比单看指标有用得多。
最后分享一个习惯:每次改模型或数据,导出三样东西——验证集 mIoU、中心线偏差曲线、以及 8 张典型场景的预测图(夜晚、雨天、逆光、磨损)。对比的不是这一次有多好,而是相对上一次改动的增量。这个记录习惯,帮我在无数次参数调整里快速排除掉了无效改动,也避免过“感觉好了但不知道哪里好了”的盲目迭代。希望帮到你——把这套流程跑通,语义分割车道线的每一个环节就不再是黑匣子了。
本文还有配套的精品资源,点击获取