简介:面向自动驾驶与计算机视觉学习者,这份压缩包提供了基于U-Net模型在TuSimple数据集上训练的车道线检测完整项目。包内共15个文件,包括7个Python脚本(覆盖模型结构、数据集处理、训练与预测流程)、2个Markdown说明文档、2个文本配置,以及4个AVI/MP4演示视频,可用于直观查看实线、虚线、湿滑路面等多种场景下的预测效果,压缩包整体约7.89MB,轻量易上手。已有581人学习,适合希望快速了解车道线分割思路、复现U-Net训练流程或作为入门参考的学习者。通过阅读源码与视频,读者能掌握图像分割模型的构建、损失函数与优化器选择、TuSimple数据预处理及结果评估方法,为后续开展自动驾驶感知研究打下基础。
1. 用U-Net在TuSimple上跑车道线检测:先搞清楚这份工程能给你什么
拿到这个压缩包的人,多半是想在车道线检测上快速跑通一套U-Net。标题很短,但信息量不少:模型用的是UNet结构,数据集是TuSimple,产物是「预测车道线的效果」。换句话说,这是把语义分割的思路直接套在车道线任务上——把每根车道线当成前景像素,训练一个端到端的二分类分割网络,再从概率图里还原出线条。这个方案最大的价值是门槛低、可复现:不需要复杂的anchor设计,不需要预处理车道线方程,显存要求也不高,一张入门卡就能跑。适合刚接触分割类车道线检测、或者想拿U-Net做baseline对比的工程师和数据爱好者。后面几章,我会按「数据怎么准备→网络怎么改→参数怎么调→坑在哪→效果怎么验」把这条路完整走一遍。
2. 从TuSimple标注到训练样本:把json折线变成能喂给U-Net的mask
2.1 TuSimple标注里不止有折线:lanes、h_samples与-999的含义
TuSimple数据集的标注是json格式,每条车道线由一组离散点组成。原始json里关键的字段有raw_file(图像路径)、lanes(所有车道线的x坐标列表)、h_samples(对应的y坐标采样点)。这里有个新手必踩的细节:lanes里的坐标和h_samples按位置一一对应,如果某一行没有这条车道线,x坐标用-999表示。转mask时不滤掉这些-999,画出来的线会直接横穿整张图,训练出来的模型也会在错误位置响应。
拿到一条json先打印看看结构,这个习惯能省掉后面很多排查时间。
import json with open('label.json', 'r') as f: ann = json.loads(f.readline()) print('原始图像:', ann['raw_file']) print('车道线数量:', len(ann['lanes'])) print('采样高度数量:', len(ann['h_samples'])) for i, lane in enumerate(ann['lanes']): valid = [(x, y) for x, y in zip(lane, ann['h_samples']) if x != -999] print(f'第{i}根线有效点数量: {len(valid)}')这段代码做的事情很简单:把json的第一条标注读出来,统计每根车道线有多少个有效点。重点在列表推导里的x != -999,TuSimple的标注里,-999表示该行不存在这条线,如果不过滤,后续绘图函数就会用异常坐标画出一堆斜线。h_samples是等间隔采样的纵坐标,不同标注文件长度可能略有差异,打印出来核对下,能帮你判断是数据问题还是代码问题。
2.2 把json转成mask:一张图几行代码,但thickness是第一个坑
常见做法是用OpenCV的polylines把离散点连成折线,画在一张全零图上。这里有个参数很多人第一次会忽略:thickness。车道线在原始图里只有几像素宽,如果mask也跟着画成1像素,U-Net训练时会极度偏向背景,因为前景像素占比太小,网络倾向于把整张图都预测成背景。我一般会把线条画到6到10像素宽,让正负样本比例不那么悬殊,训练稳定性会好很多。
import json import numpy as np import cv2 def tusimple_to_mask(json_path, img_shape=(720, 1280), thickness=8): with open(json_path, 'r') as f: anns = [json.loads(line) for line in f] masks = [] for ann in anns: mask = np.zeros((img_shape[0], img_shape[1]), dtype=np.uint8) for lane in ann['lanes']: pts = [(x, y) for x, y in zip(lane, ann['h_samples']) if x != -999] if len(pts) >= 2: pts = np.array(pts, dtype=np.int32).reshape(-1, 1, 2) cv2.polylines(mask, [pts], isClosed=False, color=255, thickness=thickness) masks.append(mask) return masks这里有两个逻辑点要说明。先做len(pts) >= 2判断,是因为只有单个有效点时没法构成线段,polylines画不出来甚至报错。再一个就是reshape(-1, 1, 2),OpenCV的polylines要求点集是N×1×2的形状,少这个维度会直接抛异常。输出是uint8的0/255图,后面可以存成png或npy,训练时统一读入,不需要在DataLoader里重复转换。
thickness的选择值得多说一句:8像素是我常用的起点。如果你的数据里弯道多、曲率大,mask画粗一点,后处理拟合时不容易断线;但太粗会把相邻车道线糊成一片,模型就分不清两条线的边界了。一般先用8,观察效果再往6或10调整。
2.3 训练集/验证集划分与数据组织方式
TuSimple官方提供的训练数据有三千多张,常见的做法是把官方json文件按8:2随机切分。我的建议是尽量不要用随机划分,因为TuSimple是按路段采集的,同一条路的连续帧画面相似度很高,随机划分很容易让同路段的帧同时出现在训练集和验证集里,验证指标虚高但换路段就露馅。如果工程包里有官方划分好的文件,直接用官方划分。
数据组织上,把每张图像路径和对应的mask路径存成一行一对的text文件,训练时用Dataset类去读。如果包里有预先生成的mask目录,加载会快很多;如果没有,第一次运行时要先全量生成一遍,这一步值得等,别在DataLoader里现场算,具体原因后面避坑章会展开说。这里给一个最小可用的Dataset实现:
from torch.utils.data import Dataset from PIL import Image import numpy as np class TusimpleLaneDataset(Dataset): def __init__(self, pairs_file, img_dir, mask_dir, size=(256, 512)): self.pairs = [] with open(pairs_file, 'r') as f: for line in f: line = line.strip() if line: img_path, mask_path = line.split() self.pairs.append((img_path, mask_path)) self.img_dir = img_dir self.mask_dir = mask_dir self.size = size def __len__(self): return len(self.pairs) def __getitem__(self, idx): img_path, mask_path = self.pairs[idx] img = Image.open(f'{self.img_dir}/{img_path}').convert('RGB') mask = Image.open(f'{self.mask_dir}/{mask_path}').convert('L') img = img.resize(self.size, Image.BILINEAR) mask = mask.resize(self.size, Image.NEAREST) return np.array(img).transpose(2, 0, 1) / 255.0, np.array(mask) / 255.0这段代码里最值得注意的细节是mask的resize插值方式。掩膜图必须用NEAREST最近邻,不能用BILINEAR或BICUBIC,否则双线性插值会在车道线边缘产生灰度过渡像素,这些中间值会让BCE损失计算变得混乱,训练出来的概率图边缘会发虚。图像本身用BILINEAR没问题,因为RGB图缩小时需要抗锯齿。
3. 搭一个能训练的车道线U-Net:网络改动、损失函数与最小训练循环
3.1 为什么是U-Net:细线目标更要靠浅层特征
车道线检测本质上是像素级的前景/背景分类,FCN这类纯卷积分割网络也能做,但实践里有个明显短板:下采样太深、上采样太浅,边缘细节恢复得不好。车道线是长条细目标,偏移一两个像素,线看起来就歪了。U-Net靠跳跃连接把编码器的浅层特征直接拼到解码器,这些浅层特征保留了更多的位置和边缘信息,对细线目标非常友好。这是U-Net在医学图像分割里反复验证过的结构优势,放在车道线上同样成立。
不少人拿着yolov8训练自己的数据集的经验来套这个任务,结果发现完全不是一回事。检测框和分割mask是两种表达方式,车道线这种长条目标用检测框来表达很别扭,而且只输出框拿不到像素级的线。U-Net作为baseline的价值就在这:结构简单,改动点少,一两天就能跑通并看到一个不错的分割效果,之后再考虑换更复杂的方法。
3.2 U-Net结构怎么改:三下三上、输出1通道的基线网络
U-Net的原始结构是为医学图像设计的,输入是单通道灰度图,输出是多类分割。用在车道线上只需要改两点:输入通道改成3(RGB),输出通道改成1(只有前景/背景两类)。下面是一份完整的U-Net实现,按我的习惯精简到三下三上,通道数从32起步,适合在256x512输入上训练。
import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block = 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.block(x) class SimpleUNet(nn.Module): def __init__(self, in_channels=3, out_channels=1, base=32): super().__init__() self.enc1 = DoubleConv(in_channels, base) self.enc2 = DoubleConv(base, base * 2) self.enc3 = DoubleConv(base * 2, base * 4) self.pool = nn.MaxPool2d(2) self.bridge = DoubleConv(base * 4, base * 8) self.up3 = nn.ConvTranspose2d(base * 8, base * 4, kernel_size=2, stride=2) self.dec3 = DoubleConv(base * 8, base * 4) self.up2 = nn.ConvTranspose2d(base * 4, base * 2, kernel_size=2, stride=2) self.dec2 = DoubleConv(base * 4, base * 2) self.up1 = nn.ConvTranspose2d(base * 2, base, kernel_size=2, stride=2) self.dec1 = DoubleConv(base * 2, base) self.out_conv = nn.Conv2d(base, out_channels, kernel_size=1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) b = self.bridge(self.pool(e3)) d3 = self.dec3(torch.cat([self.up3(b), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out_conv(d1)这个网络有两个地方值得展开说明。第一是跳跃连接,decoder每一层都把上采样结果和对应encoder的feature map做通道拼接,torch.cat([...], dim=1)就是把通道维度拼起来,这样解码器既有上采样的语义信息,又有编码器传来的边缘细节。第二是输出层,最后用1x1卷积把通道压到1,输出不加sigmoid,因为后面配合BCEWithLogitsLoss一起算,sigmoid被集成进损失函数里,数值上更稳定。
输入尺寸为什么选256x512而不是512x512?TuSimple原图是1280x720,横向构图,等比缩放到256x512正好不拉伸不变形。如果缩到128x256,车道线的细节会丢得厉害;如果上512x1024,显存占用会翻好几倍,训练速度也慢。256x512是绝大多数入门卡都能接受的平衡点。
3.3 损失函数:BCEWithLogitsLoss与类别不平衡
车道线的类别不平衡问题比一般分割任务更严重。靠近边的区域至少有百分之几的前景占比,车道线只有零点几个百分点。如果直接用普通的BCELoss,网络会很快学会把所有像素都预测为背景,因为那样loss也很低。
常见的处理手段是给BCEWithLogitsLoss设置pos_weight,也就是对前景像素的梯度放大。pos_weight=5表示前景像素的loss权重是背景的5倍。这个值不用太精确,先给5,观察训练时如果loss下降太慢就加大,如果mask出现大片假阳性的噪点就减小。另一种做法是叠加DiceLoss,它的好处是直接优化分割区域的重叠度,对类别比例不敏感,但小目标上梯度不够稳定,我一般是只用一个dice项加一个BCE项,dice的权重设成0.5左右,具体看效果。
criterion = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([5.0]))一句话说明:这里传的是一个列表,因为BCEWithLogitsLoss按通道处理,我们输出只有1个通道,所以要写成[5.0]而不是5.0。如果后续改成多通道输出(比如每个实例一根线),这个pos_weight也要改成对应通道数的列表。
3.4 最小训练循环:跑通一个epoch需要哪些代码
在配置好环境和数据之后,训练的部分其实很少。下面这个循环就是完整的最小可复现版本,优化器用Adam,学习率1e-3,再加一个StepLR在每10个epoch把学习率减半。这种配置对于U-Net分割任务来说是很稳的起点。
import torch from torch.utils.data import DataLoader model = SimpleUNet(in_channels=3, out_channels=1, base=32).cuda() criterion = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([5.0]).cuda()) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) train_loader = DataLoader( TusimpleLaneDataset('train.txt', 'images', 'masks'), batch_size=8, shuffle=True, num_workers=4, pin_memory=True ) for epoch in range(30): model.train() total_loss = 0.0 for imgs, masks in train_loader: imgs, masks = imgs.cuda(), masks.cuda() preds = model(imgs) # 原始logits,形状 B x 1 x H x W loss = criterion(preds, masks) # masks是0/1浮点,与logits直接比较 optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() print(f'epoch {epoch+1}, loss: {total_loss / len(train_loader):.4f}')这里有个新手容易踩的细节:masks不要先套sigmoid。BCEWithLogitsLoss的内部实现是先对preds做sigmoid再计算交叉熵,输入应该是未经激活的logits。如果不小心在模型输出后手动加了sigmoid,再传给这个损失函数,数值上虽然也能算,但梯度会不稳定,训练收敛明显变慢。另一个细节是pos_weight这个tensor也要放到cuda上,否则前向时会因为device不一致报错。
4. 训练参数调优与效果评估:从loss能降到多少到F1怎么算
4.1 必调参数表:从lr到pos_weight一次说清
训练U-Net车道线模型需要调整的参数不多,但每个都直接影响结果。下面这张表是我认为最有用的起点参数组合,按这个配置先跑通,再根据你的实际效果去调。
| 参数 | 建议起始值 | 调试方向 | 说明 |
|---|---|---|---|
| 输入分辨率 | 256x512 | 128x256 / 288x512 | 分辨率越低训练越快,但细节丢失明显 |
| batch size | 8 | 4 / 16 | 显存不够就减到4,同时可适当降低lr |
| 优化器 | Adam, lr=1e-3 | SGD, lr=0.02 + momentum 0.9 | Adam收敛快,SGD最终精度略稳 |
| epoch | 30 | 50 / 80 | 三千多张图,30轮基本能看出趋势 |
| pos_weight | 5 | 1 / 10 | 用于平衡前景像素占比过低的问题 |
| mask thickness | 8 | 6 / 10 | 画mask时的线条宽度,影响正样本占比 |
| 推理阈值 | 0.5 | 0.3 / 0.7 | 只影响后处理,不影响训练 |
loss降到什么程度算正常?加了pos_weight之后,BCE loss会在0.1到0.3之间浮动,越低越好,但这不代表模型达到瓶颈。如果你发现loss卡在0.5以上下不去,先检查pos_weight是不是不小心传成了1.0,或者数据加载时是不是真的读到了有前景的mask。我见过有人在Dataset里把像素归一化成0到255之外的值,导致BCE在数值上异常膨胀,排查了很久才发现是数据类型的问题。
4.2 数据增强:亮度扰动为什么比随机裁剪优先
数据增强里最容易犯的错是对车道线图像直接套用通用目标检测那套增强方案。随机裁剪要非常小心,因为车道线往往延伸到图像边缘,裁剪后大量车道线被切断,模型看到的正样本是半截线,语义被破坏。同样的道理,随机旋转也不适合,旋转会让车道线的形状偏离真实物理规律(道路上的线不会随便歪斜)。
我一般把亮度、对比度扰动放在第一位。TuSimple是白天高速路场景,光照变化其实很有限,但训练时引入小范围的亮度抖动,能让模型对白色/黄色标线的反射差异更鲁棒。实现上用PyTorch自带的ColorJitter就够,brightness给0.2、contrast给0.2,不要加太多,否则mask和图像之间会出现语义错位。水平翻转可以用,因为车道线左右对称性在这个任务上是成立的,Flip之后把mask一起翻。下面是一个常用的增强组合:
from torchvision import transforms train_transform = transforms.Compose([ transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.RandomHorizontalFlip(p=0.5), ])使用说明:这个transform要同时作用于图像和mask,ColorJitter只改图像本身,mask是二值图不需要跟着变;但水平翻转时图像和mask必须一起翻,否则标注错位导致训练出来全是错乱的线。如果发现训练集增强太重导致过拟合不减,优先降低ColorJitter的幅度,而不是去掉翻转。
4.3 评估指标:像素IOU与TuSimple官方F1的口径差异
训练过程中的评估和最终效果评估要分开看。训练时用像素IOU就够了,它反映的是mask预测的质量,计算简单、跑得快。但TuSimple官方的评测指标是F1,它跟像素IOU的口径完全不同:官方评测先把预测出来的车道线点集和真实车道线点集做匹配,落在距离阈值内算命中,再按线粒计算precision和recall,最后算F1。也就是说,官方指标关心的是「线找没找对、线画得准不准」而不是「每个像素对不对」。
这两个指标很容易出现背离的情况。比如模型预测出一根轮廓发虚的宽线,像素IOU可能不错,但线心的位置偏移了,官方F1就拉低了。反过来,预测线很细很锐利,但有一小段断了,IOU不高,但官方F1可能很高,因为断线不影响线级别的匹配。所以如果你最后要跟别人对比,必须用TuSimple官方F1;如果只是自己验证U-Net效果,像素IOU加上逐个可视化就够用。我这里给一个训练阶段检查IOU的最小实现:
def compute_iou(preds, masks, thr=0.5): preds = (preds > thr).float() inter = (preds * masks).sum() union = (preds + masks).clamp(max=1).sum() return (inter + 1e-6) / (union + 1e-6)这个函数很简单,inter是预测和真实mask重叠的像素数,union是两者并集的像素数,加两个1e-6的epsilon是为了防止分母为0。在训练循环里每隔几个epoch跑一次验证集,看到IOU开始下降而loss还在降,那说明模型开始过拟合了,这时候早停比继续训练划算。
5. U-Net车道线训练常见问题:5个翻车现场与排查思路
5.1 车道线太细:loss下降慢、mask全黑
现象:训练了好几个epoch,loss在0.5以上波动降不下去,验证集的预测mask几乎全黑。
原因:车道线像素占比本身就低,如果你把mask画成1像素宽,那么正样本占比可能只有0.1%到0.5%。在这种极度不平衡的情况下,即使全部预测为背景,loss也不算高。网络没有足够的梯度信号去学习前沿的细节。
解决:把标注转mask时的thickness从1改成6以上;给BCEWithLogitsLoss增加pos_weight=5再观察,如果还不行就提高到10;或者直接往loss上加一个DiceLoss项,Dice对前景区域的比例不敏感,能有效拉高前景分割质量。这三个改动按顺序做,做完之后loss通常会在两三个epoch内明显下降。
5.2 验证IOU高但可视化全是噪点
现象:验证集IOU有0.6以上,但你拿预测图叠加到原图上一看,车道上全是零散的白色噪点,轮胎印、路面缝隙都被预测成了车道线。
原因:验证时用了整体准确率作为指标,在不平衡数据集上准确率天然虚高;另一个原因是没有做后处理,直接拿概率图过了0.5阈值就去可视化,小噪点全被当成了前景。如果模型里用了BatchNorm或Dropout,还要检查推理时是不是忘了model.eval(),这会导致BN和Dropout还在用训练模式,预测结果带随机性。
解决:可视化验收时不看指标,直接看预测图和原图的叠加。推理端加一层连通域过滤,把小于30像素的区域直接去掉。务必在加载权重后调用一次model.eval(),再测一次指标对比,往往能发现验证指标被高估了不少。
5.3 同一份代码换机器精度对不上
现象:把自己的训练工程拷到另一台机器上,同样的数据同样的超参,最后验证指标差了一截,甚至中间loss曲线都不一样。
原因:训练环境的PyTorch版本、CUDA版本不同,卷积和池化的底层实现有非确定性;另外显存大小不同导致你无意识地改了batch size,batch size变了但lr没同步调整,相当于换了一套训练配置。这两类问题叠加起来,效果对不上很常见。
解决:在代码入口固定随机种子,包括torch.manual_seed和numpy.random.seed;如果追求严格复现,可以用torch.use_deterministic_algorithms(True),代价是训练速度变慢。更重要的习惯是,把batch size、lr、增强参数、mask thickness全部写进一个config文件里,每次跑实验都记录。这样换机器后按config恢复环境,精度对不上的问题基本能定位到是不是依赖版本变了。
5.4 数据加载慢到GPU利用率只有十几
现象:GPU显存占用正常,但利用率一直在百分之十几,训练一个epoch要半小时以上,感觉像在跑CPU课程。
原因:最常见的错误是在DataLoader里实时读json、实时画mask,这意味着每个epoch都要在CPU端重新跑一遍OpenCV的polylines。这个操作非常慢,尤其是lanes列表很长几百个点,循环绘制耗时严重。另一个问题是mask以png格式存储,IO本身不慢,但如果你在Dataset里每次resize,也会拖慢整体加载速度。
解决:预处理把mask提前生成并保存好,训练时只读现成的png或npy文件,不要在Dataset里做json解析和画线。DataLoader设置num_workers=4到8,pin_memory=True,这样数据加载和GPU训练能重叠起来。TuSimple只有几千张图,整个数据集完全可以缓存进内存,Dataset里加一个字典做缓存,速度会再快一截。
5.5 弯道处断线:mask标注与后处理的双重问题
现象:直道路段预测效果不错,但弯道和匝道处车道线断成几截,连通域计数临时多了好几根。
原因:TuSimple的标注本身是离散点线性连接,弯道处采样点稀疏,折线会切弯,和真实曲率有偏差。mask只画了8像素宽,弯道一旦曲率大,预测出的前景在这个区域特别薄,后处理时很容易被面积过滤规则误删。
解决:绘图时对采样点做一次三次样条插值再画线,让弯道区域的mask曲率更平滑。后处理的过滤阈值不要设得太高,min_area给到30左右。最后一招是加曲线拟合,把每个连通域的点做多项式拟合,把断线重新接起来。别指望训练能把断线彻底干掉,后处理才是弯道修复的主力。
6. 从预测mask到可视化效果:连通域过滤与车道线拟合
6.1 连通域过滤:把概率图变干净
预测出的概率图直接叠到原图上会很难看,需要过滤掉零散噪点。常见做法是用OpenCV的connectedComponentsWithStats做连通域分析,按面积阈值保留目标区域。
import cv2 import numpy as np def filter_by_area(prob, thr=0.5, min_area=30): mask = (prob > thr).astype(np.uint8) n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) out = np.zeros_like(mask) for i in range(1, n): if stats[i, cv2.CC_STAT_AREA] >= min_area: out[labels == i] = 1 return out说明:背景label是0,遍历从1开始。stats的CC_STAT_AREA列直接给出每个连通域的像素数,小于min_area的直接丢弃。min_area的设置要跟图像缩放比例挂钩,如果推理时输入是512x1024,30可能偏严,调整到100左右更合适。这个函数只保留面积满足要求的连通域,断线问题在视线上会明显改善。
6.2 曲线拟合:每个连通域一条多项式
过滤后的每条连通域本质上都是一堆像素坐标,要得到平滑的线条,常见做法是按x坐标做聚类,再对每一簇做多项式拟合。这里给一个简化版本,假设你已经把像素点按车道线分好了簇,用二次多项式拟合最合适,因为真实车道线在道路平面上的投影接近抛物线。
def fit_polyline(points, degree=2): xs = points[:, 0].astype(np.float32) ys = points[:, 1].astype(np.float32) coef = np.polyfit(ys, xs, degree) poly = np.poly1d(coef) ys_plot = np.linspace(ys.min(), ys.max(), 100) xs_plot = poly(ys_plot) return np.stack([xs_plot, ys_plot], axis=1)这里用y作为自变量拟合x,原因是车道线在图像里大多是竖条,如果反过来用x拟合y,弯道弧度大的地方会出现一个x对应多个y的歧义,拟合出来的线是乱的。输出是一组平滑点,可以直接用cv2.polylines画到原图上。
6.3 效果验收:把mask叠到原图上逐帧检查
跑训练的时候,每到几个epoch就导出一批预测图,把mask和原图按0.6和1.0的权重叠加,存成一张jpg。这样能看到模型在直道、弯道、阴影分别是什么表现。我最常做的是直接抽一段视频让模型逐帧预测,输出带线条的合成视频,这比看单张图能更快发现问题。像弯道断线、直道抖动这类问题,单张图可能不明显,一放到连续帧里就原形毕露了。
做完预测之后,再把训练好的模型权重、推理脚本、后处理参数整理好,这样整个流程才算闭环。我现在养成的习惯是每跑通一个项目就把「数据准备脚本+训练配置+推理脚本」三件套固定下来,下次做任何U-Net相关的任务直接复用这套骨架,能省非常多是时间。车道线检测这个方向,U-Net可能不是最终落地的最优解,但它作为baseline的价值很大——统计指标、后处理流程、数据pipeline全是这套东西打底。希望这个工程能帮你把第一步迈得稳一些,也希望这些踩坑经验能让你少折腾几个晚上,祝顺利。
本文还有配套的精品资源,点击获取