☰
高分辨率车道线语义分割实战:从数据集处理到模型训练避坑指南
2026/10/1 5:35:18 网站建设 项目流程

简介:这是一份面向计算机视觉与自动驾驶研究者的高分辨率高速车道线图像语义分割数据集,包含6类分割标签(背景、白实线等),已按约2000张训练集与800张测试集划分完毕,可直接用于分割模型训练与评估,适合具备一定深度学习基础的学习者进行车道线检测实践。资源共约2000个文件,压缩包大小约430MB,其中png/jpg图像对应原始图片与mask标签,另有classes类别说明txt以及一个可视化Python脚本,便于快速查看原图、GT及叠加蒙版效果。目前已有152人学习该资源。配套的可视化脚本可随机抽取图片展示原始图、真实分割标签及原图蒙版叠加结果,帮助读者直观核对标注质量;若需进一步改进分割网络,还可结合作者专栏中的UNet、SwinUNet、TransUNet等模型思路,形成从数据到模型训练的完整闭环。

1. 高分辨率车道线语义分割数据集:2,800张能做什么、不能做什么

做高速车道线感知时碰到一个高分辨率图像语义分割数据集:6个分割类别,约2,800对图像和标签。这个规模放到语义分割领域不算大,但胜在场景聚焦,整批数据都来自高速车道线,比一次性丢给你十二万张杂乱图片更适合快速验证算法。它能解决的是车道线像素级归属问题:哪些像素是实线、虚线、双黄线、路缘还是背景,而不是目标检测那种画框粒度。适合刚入门图像分割数据集制作、正想跑通语义分割pipeline的工程师,也适合需要用真实数据测试语义分割模型差异的选型阶段。它不能做的是:不增强、不调loss、不处理类别不平衡,就指望它直接部署到夜间匝道或雨天反光场景。这个容量决定了它更适合当预训练底座或迁移学习起点。

2. 把原始图像与标签整理成可训练的数据集:目录结构与标签校验

2.1 先看清目录结构,别急着写训练代码

很多人拿到分割数据集的第一步就是写DataLoader,结果训练时才炸出各种文件错位。我一般先花五分钟做目录体检。假设你解压后的目录长这样:

lanedata/ images/ 00001.jpg 00002.jpg labels/ 00001.png 00002.png

第一件事是统计文件数和后缀:

ls images | wc -l # 期望约2800 ls labels | wc -l # 期望约2800 file images/00001.jpg labels/00001.png

这一步跑完,能发现常见的文件缺失、后缀带大写、标签是jpg压缩图等问题。jpg做语义标签是灾难,压缩会让边缘产生混叠,等训练完你会发现车道线边缘像毛玻璃。紧接着写一个Python脚本读一批图像,确认尺寸、通道、标签的像素值分布:

from PIL import Image import numpy as np import os img_root = "lanedata/images" lab_root = "lanedata/labels" imgs = sorted(os.listdir(img_root))[:20] for name in imgs: img = Image.open(os.path.join(img_root, name)) lab = Image.open(os.path.join(lab_root, name.replace(".jpg", ".png"))) print(name, img.size, img.mode, lab.size, lab.mode) arr = np.array(lab) print(" label unique:", np.unique(arr))

这段代码的作用是摸清图像尺寸是否全图统一、标签是单通道索引图还是三通道伪彩色图。数据集一般会在说明文档里写清每类像素值的对应关系,但还是要以实际读出来的unique值为准。如果unique值是[0,1,2,3,4,5],那恭喜,标签已经是索引图,可以直接当成模型输出通道数来用。如果unique值得到[255,128,64]这种灰度值或者三通道RGB值,你需要做一次映射,把像素值归一到0~5的类别编号。我见过有人把标签原样读成RGB三通道喂给模型,loss直接原地爆炸,因为模型输出6个通道而标签是3通道。这不是算法问题,是数据结构没对齐。图像分割数据集制作规范里,标签必须是单通道索引图,每个像素值代表一个类别,不能是调色板展开后的彩色图。

2.2 统一尺寸与切patch,高分辨率不是直接塞GPU的理由

这批数据叫“高分辨率”,但具体多高要实测。如果是2000x1000乃至4000x3000,直接把整图放进网络是不现实的,显存受不了。常见做法是切patch训练、整图推理。对车道线这种细长物体,切patch时要注意不能让一条完整的线被切得支离破碎。我先给一个统一resize脚本,把图像和标签同步缩放到合适范围,比如高度1024、宽度2048。关键点:标签缩放必须用最近邻插值,否则类别边界会被插出过渡灰度值。

from PIL import Image def resize_pair(img, lab, target=(2048, 1024)): img = img.resize(target, Image.BILINEAR) lab = lab.resize(target, Image.NEAREST) # 标签绝不能用线性插值 return img, lab # target传(w,h),对应PIL的size

这里的target传的是(width, height),PIL里是图像尺寸,不是numpy的shape。为什么标签用NEAREST?因为双线性会在类别边界产生中间值,比如0和1之间插出0.5,而语义分割的交叉熵loss期望标签是整型类别。一旦出现中间值,CrossEntropyLoss会把它当成另一个类别,或者在你做one-hot转换时直接报错。这是高分辨率数据里最常踩的坑。如果你不想整体缩放,也可以做滑窗切patch。切patch时要保证图像和标签裁剪坐标完全一致,起点坐标取整除32的倍数,这样后面网络下采样时不会产生对齐偏差:

def crop_pair(image, label, origin, size): x, y = origin w, h = size return image[y:y+h, x:x+w], label[y:y+h, x:x+w]

传入的origin坐标最好都是32的整数倍。比如800x600这种非对齐尺寸,会导致下采样后的特征图分辨率对不上标签。很多人不以为然,最后评估mIOU时发现整体偏低,找半天想不到是这个原因。高分辨率图像尺寸大,一味的resize会丢失细线信息,所以patch大小建议在512x512到896x1024之间,保证每条车道线在patch里至少延续200个像素。

2.3 划分训练集和验证集:别用随机划分把场景打散

2800张不算多,划分方式直接影响验证集的可靠性。最简单是随机按比例划分,但对车道线场景,我建议按路段或拍摄序列分组。如果数据是按连续视频帧抽的,前后帧高度相似,随机划分会把同一段路的图像同时塞进训练集和验证集,让验证分数虚高。具体操作是看一眼文件名前缀。比如highway_001_01.jpg,下划线前的highway_001是序列号,按序列号划分,而不是按单帧划分。

import os import random from collections import defaultdict img_root = "lanedata/images" seq_map = defaultdict(list) for name in os.listdir(img_root): seq = name.split("_")[0] seq_map[seq].append(name) seqs = list(seq_map.keys()) random.seed(42) random.shuffle(seqs) split = int(len(seqs) * 0.8) train_seqs, val_seqs = seqs[:split], seqs[split:]

这段代码把每个序列作为一个整体分配,确保验证集与训练集的场景重合度低。如果你确认这批数据是互不关联的单帧图,那用随机划分也没问题。划分完后生成train.txt和val.txt,每行写相对路径,后续DataLoader直接读这两个文件,避免每次启动都重新遍历目录。此时才算把数据交给模型。不要跳过标签校验这一步,我后面会专门列避坑章节,但这里先记住:珍惜一次人工可视化检查的20分钟,能帮你省下两个调试的晚上。

3. 选择语义分割模型:从U-Net到DeepLabV3+的选型与训练配置

3.1 车道线为什么必须用语义分割而不是检测框

有人会问,既然只关心车道线,能不能用目标检测的YOLOv8-seg之类?YOLOv8-seg做的是实例分割或者带mask的检测,车道线在图像里往往横贯整幅,而且同一条虚线被车头遮挡后会分成多段,实例ID不稳定。语义分割算法处理的是像素级分类,不管同一条线分几段,只要是同一类别就算对,更适合车道线感知。所以这个数据集从一开始就是围绕语义分割设计的,后面所有训练配置都按6类分割来写。如果你想让推理速度更快,可以用轻量级分割模型,但前提是你已经在baseline上验证过类别边界质量。

3.2 模型选型:编码器选ResNet50还是轻量级Backbone

高速场景对精度和实时性都有要求。我常用三个模型做对比:

模型编码器参数量适合场景显存占用量
U-NetResNet50约32M小数据集、细线保留中
DeepLabV3+ResNet50约40M多尺度物体、边界较好较高
SegFormer-B2MiT-B2约24M高效推理、感受野大较低

2800张数据量不大,直接上Swin-Transformer这类大模型很容易过拟合。我一般会先用DeepLabV3+配ResNet50做baseline,因为ASPP空洞卷积天然适合高分辨率小目标,车道线细长,普通下采样会把线缩到只剩几个像素。U-Net优势在于跳跃连接能还原边缘细节,但原版U-Net对高分辨率输入的编码效率不如ASPP。SegFormer有分层Transformer特征,如果推理设备支持TensorRT,也可以做到实时。如果你的部署平台是Jetson Orin这类算力有限的设备,就把候选模型定为SegFormer-B2和MobileNetV3-Large做decoder的轻量版本。

3.3 最小可训练脚本:从DataLoader到fit循环

假设你已经把图像和标签整理成了两个文件夹,并切成了训练用patch。下面是一个PyTorch训练脚本骨架,覆盖数据读取、模型加载、loss和优化器:

import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as T from transformers import SegformerForSemanticSegmentation class LaneDataset(Dataset): def __init__(self, img_list, img_root, lab_root, size=(512, 512)): self.img_list = img_list self.img_root = img_root self.lab_root = lab_root self.size = size self.transform = T.Compose([ T.Resize(size, T.InterpolationMode.BILINEAR), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.img_list) def __getitem__(self, idx): name = self.img_list[idx] img = Image.open(f"{self.img_root}/{name}").convert("RGB") lab = Image.open(f"{self.lab_root}/{name.replace('.jpg', '.png')}") img = self.transform(img) lab = T.Resize(self.size, T.InterpolationMode.NEAREST)(lab) lab = torch.as_tensor(np.array(lab), dtype=torch.long) return img, lab # 以SegFormer为例,输出6类 model = SegformerForSemanticSegmentation.from_pretrained( "nvidia/mit-b2", num_labels=6 ) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01) criterion = nn.CrossEntropyLoss(ignore_index=255)

这个脚本里有几个参数容易让人迷惑:T.Resize(size, NEAREST)里的size是(W, H),和PIL的resize行为一致。ignore_index=255用来忽略标签里的无效像素,如果数据集没有255可以去掉。训练循环就是标准的前向与反向,注意要把model.train()和model.eval()切清楚:

for epoch in range(60): model.train() for img, lab in train_loader: img, lab = img.cuda(), lab.cuda() out = model(pixel_values=img).logits loss = criterion(out, lab) optimizer.zero_grad() loss.backward() optimizer.step()

SegFormer的model(pixel_values=img)返回对象里,logits是形状[B, 6, H, W]的分数图,已经上采样到输入尺寸。如果你用DeepLabV3+或其他库,输出接口不同,但loss计算逻辑一致。这个脚本跑通后就拥有了一个最小可训练系统,后续调参数就往这个框架里填。但注意,基线交叉熵在2800张这种小数据集上往往跑不出好效果。如果验证集mIOU一直不动,问题很可能出在loss和数据均衡上,下一章详细处理。

4. 训练过程中的6个关键参数与loss设计:让车道线细节不再糊掉

4.1 loss组合:CE + Dice是车道线最优解之一

车道线分割最大的痛点是类别不平衡。图像里95%以上像素是背景,一条车道线可能只占几十个像素。如果直接用交叉熵,模型学到“全部预测背景”就能拿到很低loss,验证时mIOU也能有0.8多,但可视化全黑。我建议用CrossEntropy和DiceLoss组合,公式如下:

loss = 0.5 * CE_loss + dice_loss

Dice计算的是类别预测区域与标签区域的交集比并集,对小目标敏感。在PyTorch里可以简单实现:

def dice_loss(pred, target, num_classes=6, eps=1e-7): pred = pred.softmax(dim=1) target_onehot = torch.nn.functional.one_hot(target, num_classes).permute(0, 3, 1, 2).float() intersection = (pred * target_onehot).sum(dim=(2, 3)) cardinality = pred.sum(dim=(2, 3)) + target_onehot.sum(dim=(2, 3)) dice = (2 * intersection + eps) / (cardinality + eps) return 1 - dice.mean()

这个实现里每个类别都参与dice,而不是只算前景类。注意one_hot之后通道在最后一维,需要permute到[B, C, H, W]。我把CE和dice取0.5:0.5的权重,如果你发现模型预测很激进度,比如车道线过粗,可以把dice权重调到0.3;如果预测太保守,断断续续,就把dice权重调到0.7。这个比例没有绝对玄学,按你视觉评估的结果调。除了dice,也有人用focal loss,但车道线不是难例问题,而是稀缺问题,focal的贡献不如dice直接。

4.2 学习率策略:poly衰减比阶梯式更适合分割

语义分割任务的常用学习率策略是poly,即学习率随迭代次数指数衰减:

def poly_lr(optimizer, base_lr, curr_iter, max_iter, power=0.9): lr = base_lr * (1 - curr_iter / max_iter) ** power for g in optimizer.param_groups: g['lr'] = lr

如果你用阶梯式下降,比如每30轮除以10,经常会遇到在第31轮loss跳一个尖峰,因为后期lr太大会反复跳出收敛区域。poly则平滑过渡,配合warmup更好。在训练前10%的迭代里,把lr从0升到base_lr,可以避免开局过大更新导致标签噪声放大。base_lr我一般设1e-4到3e-4,如果用了混合精度,可以适当放大到5e-4。这里的max_iter是一次训练的迭代总数,每个epoch循环里调用一次函数即可,优化器会自动更新参数组里的lr。

4.3 输入尺寸与batch size:高分辨率要求显存预算

这里列一组我实际跑过的组合,大家可以参照自己的GPU显存来定:

GPU显存输入尺寸batch size梯度累积备注
8GB512x51242训练慢但能跑
12GB896x51241兼顾细线与显存
24GB1024x102461推荐配置

车道线是细长结构,输入高度不要低于384,否则一条10像素宽的线缩成2像素,网络根本无从学习。我一般会把输入宽度保留在2048甚至4096的patch上,用增强来把线宽控制在5到15像素这个区间。如果显存不够,梯度累积是一个有效的后悔药,它并不改变loss计算方式,只是把多个batch的梯度累加后再更新,效果近似等价于更大batch。

4.4 数据增强:翻转、光照,以及别做几何扭曲

高速车道线有强方向性,水平翻转会让左侧虚实线与右侧混淆,如果数据集标注时没有区分左右车道线,可以开翻转;如果区分了,必须关闭。这是数据增强里最容易被忽略的细节。我常用的增强有:

T.RandomHorizontalFlip(p=0.5), # 如果左右类别是不同标签,则p=0 T.ColorJitter(brightness=0.3, contrast=0.3),

不要用随机旋转和透视变换。车道线是刚性直线,旋转15度后虚线变斜线,与真实分布不符。除非你确认部署场景真有坡度起伏,否则这类“过度增强”会让模型学出奇怪幻觉。高分辨率场景里,随机裁剪本身就能提供大量位置变化,配合光照抖动已经足够。还可以用RandomAffine里的scale-only模式,只做轻微缩放,保持纵横比。

5. 车道线分割典型坑:标签错位、类别不平衡与高分辨率显存不足

5.1 标签与图像错位导致预测整体偏移

现象:训练loss正常下降,可视化时预测的车道线位置总是比原图偏右或偏下几像素。

原因:最常见是数据预处理时,图像用了CenterCrop,标签却在前一步做了RandomCrop;或者resize时用opencv的cv2.resize读取BGR,标签用PIL读取RGB,转来转去宽度对齐错了。另一个隐蔽原因是裁剪坐标没有取偶数或32的倍数,在下采样时四舍五入导致特征图与标签分辨率不对齐。高分辨率图特别容易触发这个问题,因为像素偏移在放大到1080p后视觉差异非常明显。

解决:统一图像和标签的预处理管线,标签只用NEAREST插值。裁剪坐标取32的倍数。在训练代码里加一个断言:

assert img.shape[2:] == lab.shape[1:], "image and label size mismatch"

用这个断言把同步问题扼杀在数据读取阶段。如果你在验证时发现掩码边缘错位,先检查是不是resize参数传反了,width和height对调也是常见翻车点。

5.2 验证mIOU看着还行,可视化却翻车

现象:mIOU到0.85,但画出来的掩码边界全是锯齿,车道线断成一条虚线,甚至中间有空洞。

原因:类别不平衡让模型学会了预测连通域中概率较高的区域,对细长结构的轮廓不敏感。DiceLoss只能缓解数量级不平衡,它自身也偏向形状较大的区域。高分辨率下,一条车道线在像素上比背景少了两个数量级,模型倾向于把低置信度的区域置为背景,造成断线。

解决:在后处理阶段做连通域分析和形态学骨架修复。我常用的手段是先用3x3的闭运算把断裂处连接,再用5x5的中值滤波去孤立点,最后按类别保留最大连通域:

import cv2 mask = prediction.astype(np.uint8) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=1)

注意闭运算会把两条平行的线粘连,如果类别之间有明确的间隔,比如双黄线,先用类别ID分开再处理。这一步属于推理阶段的补救措施,不是训练问题。如果你发现训练时dice loss降不下去,优先怀疑标签本身有缺口,而不是模型。

5.3 高分辨率图像把所有GPU显存吃光,训练直接OOM

现象:CUDA out of memory,即使batch size降到2也崩。

原因:输入分辨率太高,且没有做任何显存控制。也有人是backbone的BN统计量太大导致的峰值显存。高分辨率数据集最常见的错误就是把整图喂进去,忘了训练patch和推理整图的区别。

解决:先确认训练输入分辨率是不是被resize到了2000x4000。把输入降到1024x1024,开启混合精度和梯度累积。用torch.cuda.amp:

scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): logits = model(img) loss = criterion(logits, lab) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()

如果单卡还不行,切成patch训练,每个patch保持完整线段的上下文,patch大小至少256x512,重叠16像素。推理时再用滑窗拼接,这样训练和推理的分辨率不会差太多。不要试着用梯度累积去解决所有OOM,它只影响更新频率,不能降低单次前向的峰值显存。

5.4 数据只有2,800张,训练集还按背景占大头,过拟合严重

现象:训练loss降到0.2,验证loss反而涨到0.8,验证mIOU不升反降。

原因:样本量小,模型把背景纹理背下来了,没有学到车道线语义。背景里的路肩、护栏、树影这些纹理高度重复,模型容易记住这些纹理和类别的组合,一旦验证集出现新场景就懵。

解决:除了前面说的增强,还可以用预训练backbone。从ImageNet预训练的ResNet50出发,比随机初始化收敛快得多。另一个有效做法是加载一个在Cityscapes上预训练的DeepLabV3+权重,把最后分类层换成6类。迁移学习对小数据集是后悔药,别从零起步。另外,训练轮数不要超过80,配合早停策略,监控验证loss,连续5轮不降就保存最优权重。2800张数据的模型参数量控制在40M以内,否则很容易记住训练集。

5.5 推理时直接把全图塞进去导致残差犄角

现象:训练时用512x512 patch,跑通验证很顺利,一到整图推理就爆显存,或者输出错位。

原因:推理尺寸与训练尺寸差异太大,插值放大了累积误差。高分辨率图的尺寸可能是训练patch的16倍,模型在训练时从没见过这么大跨度的尺度。

解决:推理也切成与训练尺寸接近的patch,做滑窗拼接。拼接重叠区域用线性权值融合,消除拼缝。这是高分辨率分割的常识,但很多新手会忽略。拼缝的产生是因为每个patch独立预测,周围概率被截断。重叠区域权值从边缘到中心线性过渡,能有效减少视觉拼缝。这种后处理代码不复杂,但能让可视化效果提升一档。

这几条是我做过分割项目后留下的血泪经验。每一类问题在第2章和第4章都有对应的解决基础,碰到具体现象时先按这里定位,能少走弯路。如果你发现自己的错误不在上述列表里,那大概率是读取路径写错了,检查os.path.join的拼接,Windows和Linux的分隔符也能坑人。

6. 让模型在高速场景更稳的四个技巧:后处理、多尺度与边缘细化

最后一个环节,写四个我实际用下来收益最明显的技巧,它们不需要重新训练,只要在推理阶段加几行代码。

第一,连通域过滤。把预测掩码按类别提取连通域,面积小于阈值的直接丢弃。高速场景里,远处的小点是噪声,不是车道线。对6类分割,阈值设为50像素能去掉大部分孤立噪点。这个阈值不是固定的,可以按输入分辨率的千分之一估算。

第二,多尺度推理。用0.6、1.0、1.4三个尺度对图像缩放后分别预测,把概率图插值回原尺寸再求平均。多尺度对大目标更稳,车道线这种细长结构受益有限,但能防止线被下采样吞掉。只在推理阶段使用,别加入训练,否则每次训练都要做三倍前向,划不来。

第三,帧间时序平滑。如果是视频流输入,用最近N帧的概率做指数加权平均,比帧内后处理更有效。这一点要记录在部署代码里,不在训练阶段处理。指数权重的衰减系数设0.7,对突然出现的误检有很好的压制作用。

第四,边缘细化。把分割概率图与图像梯度图结合,在预测结果中保留车道线边缘处的尖锐度。简单做法是用拉普拉斯算子的梯度幅度乘到概率上,让边缘更锐利。如果你发现闭运算把两条线粘连,边缘细化也能帮忙分开。

我自己的习惯是:跑通baseline之后,先看坏案例,把断线、漏检、错检分别统计,再决定增强还是后处理。模型不一定是调得越花越好,2,800张的训练集能稳定输出完整车道线,已经达到预期。希望这篇整理能帮你在类似的高分辨率车道线分割项目上少熬几个夜,祝训练顺利。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询