
简介面向自动驾驶与智能交通场景的U-Net道路目标语义分割项目提供基于PyTorch的完整实现适合具备一定深度学习基础、希望动手实践图像分割的开发者。资源包共7个文件以Python脚本为主另含环境配置与项目说明文档涵盖数据加载、模型构建、训练、预测及工具函数等核心模块压缩包仅10KB轻量易部署。目前已有118人学习可作为从零搭建语义分割系统的参考模板。项目不仅演示了U-Net编码器-解码器结构在像素级分类中的应用还展示了数据预处理、训练调参与性能评估如mIoU的完整链路便于读者快速理解工程化实现思路并迁移到实际道路分析任务中。1. 道路目标语义分割为什么绕不开 U-Net先看清问题再选模型做自动驾驶或者道路巡检的人多半都被同一个问题卡过你明明把车、行人、车道线都检测出来了可到了晚上或者雨天目标框乱跳边缘糊成一团。yolo 这类目标检测给的是“框”框里是什么、边界在哪、路面和路肩怎么分它说不清。这时候要的是像素级分类也就是语义分割——每个像素都回答“这是路、这是车、这是树还是天空”。而道路场景恰恰是类别多、边缘细、远处目标小一般的 FCN 会把细节抹掉U-Net 靠一条“编码-解码跳跃连接”的结构把下采样丢失的空间信息直接拼回来成了这个领域最稳的起点。这篇我按自己做道路分割的完整流程讲数据集怎么做、模型怎么搭、训练怎么调、推理有什么坑最后给你几条能直接用的进阶技巧。适合刚接触语义分割、被检测模型精度卡住、想自己从数据到部署跑通一条线的工程师。2. 数据集准备道路语义分割的成败不在模型在标签2.1 类别定义与标注规范先定好 8 类还是 19 类别等标完再改做道路分割第一件事不是写模型而是定“到底分哪几类”。常见的有两类方案一类是 Cityscapes 风格的 19 类包含 road、sidewalk、building、wall、fence、pole、traffic light、traffic sign、vegetation、terrain、sky、person、rider、car、truck、bus、train、motorcycle、bicycle另一类是自动驾驶感知常用的简化 8 类只保留 road、lane marking、vehicle、pedestrian、traffic sign、traffic light、curb、background。我的建议是如果项目要过验收或者做论文用 19 类如果是给公司内部检测系统做辅助用 8 类就够。类别太多会带来两个实际麻烦一是标注成本翻倍二是模型容易把“墙”和“围栏”这类相邻类别搞混最终 mIoU 被拉低。标注规范必须写死在标注文档里。比如“道路”只算可行驶路面路肩和停车位线不算“车辆”包含轿车、SUV、货车但自行车归类到“骑行者”还是“车辆”“交通标志”是只算标志牌本身还是连同柱子一起这些含糊点是标注返工率最高的来源。我一般会先拿 20 张典型场景图自己和同事各标一遍算一下标注员之间的一致性用 IoU 算低于 0.7 就说明标签定义有问题要重新细化规则。2.2 标注工具选型与 Labelme 转掩码从 JSON 多边形到训练用的 PNG标注工具我常用 Labelme原因很简单开源、单机可用、导出的是 JSON 多边形方便批量改。Labelme 的标注流程是画多边形每个多边形对应一个类别。但 U-Net 训练需要的是和原图同尺寸的单通道掩码图每像素一个类别 ID所以必须把 JSON 转成 PNG 掩码。这里给一个我常用的转换脚本它处理了 Labelme 生成的每个 JSON 文件并把类别名称映射到整数 IDimport json import os import numpy as np import cv2 from glob import glob # 类别映射必须和标注时用的 names.txt 完全一致 CLASS_MAP { background: 0, road: 1, lane: 2, vehicle: 3, pedestrian: 4, traffic_sign: 5, traffic_light: 6, curb: 7, } def json_to_mask(json_path, output_path, img_shape): with open(json_path, r, encodingutf-8) as f: data json.load(f) # 掩码用 uint8正好对应类别 ID 0-255 mask np.zeros((img_shape[0], img_shape[1]), dtypenp.uint8) for shape in data[shapes]: label shape[label] if label not in CLASS_MAP: continue points np.array(shape[points], dtypenp.int32) # 填充多边形类别 ID 直接写进掩码值 cv2.fillPoly(mask, [points], CLASS_MAP[label]) cv2.imwrite(output_path, mask) # 批量处理 for json_path in glob(./annotations/*.json): json_to_mask(json_path, json_path.replace(.json, .png), (1080, 1920))这段脚本的关键点是cv2.fillPoly的填充顺序如果两个多边形重叠后画的会覆盖先画的。所以标注规范里要规定优先级比如“车辆”永远画在“道路”之上否则路面上有车时车的多边形会被道路覆盖训练时车这一类的像素就消失了。另一个参数是uint8类型类别 ID 超过 255 才会溢出常规分割任务不会但如果你把类别从 19 类扩展到带实例 ID 的几百类就要换uint16否则数据直接损坏。每个 JSON 转换前最好打印一下该文件里的类别清单和CLASS_MAP比对漏了某类时脚本静默跳过掩码里就缺了那一类训练时损失函数会一直报错或者精度缺失。2.3 数据增强白天夜间兼顾的 6 种变换与参数道路分割的数据增强核心是让模型别把“亮度”当特征。原始数据可能大部分是白天晴天夜间样本少模型很容易在夜间把路灯当行人、把车灯高光区域当车道线。我一般会在训练管线里做这些在线增强水平翻转概率 0.5、随机缩放0.5~2.0 倍之后再裁剪回固定尺寸、亮度对比度抖动亮度因子 ±0.3、高斯模糊概率 0.1应对雾天、随机遮挡在图像上随机挖矩形块模拟前方被车辆挡住的情形、以及 HSV 色相偏移应对不同颜色的路面。注意增强要同时作用于原图和掩码而且掩码不能用插值必须用nearest模式。OpenCV 的resize默认用双线性直接用在掩码上会产生类别间过渡的“假标签”训练时模型会去学这些不存在的边界。光照增强有一个坑如果只是为了夜间效果直接调亮度会把黑夜变灰。更好的做法是单独准备一套夜间数据集或者用 ISO 噪声模拟。否则模型会在极暗区域把路面和天空混淆因为两者的像素值都接近 0。3. 模型构建与训练策略U-Net 的每层设计都有原因3.1 从编码器到解码器为什么跳跃连接能保住边界的细节U-Net 的编码器部分就是不断下采样把图像从 512x512 缩到 16x16 甚至更小每一层提取的特征越来越抽象——高层的特征知道“这是车的形状”但不知道“车轮边缘具体到哪几个像素”。解码器把特征图逐步上采样回原尺寸但上采样转置卷积或插值本身会丢失高频细节边缘变得模糊。这时跳跃连接把编码器同尺度的特征图直接拼接过来等于给解码器送了一份“当时的原始细节”。这就是 U-Net 最核心的设计细节信息不是靠网络记住的而是靠通道拼接“抄”过来的。在道路场景里车道线、路缘石这类细长结构的宽度可能只有 2~3 个像素。没有跳跃连接解码器上采样后这些细线会断成虚线有了跳跃连接虽然还是有损失但连续性明显改善。另一个容易忽略的点是U-Net 原始论文里编码器和解码器的每一层是固定的卷积核数量64、128、256、512但实际工程里输入图片过大比如 1920x1080时显存不够常见做法是把初始通道数降到 32 或 48然后翻倍。这不会显著降精度因为道路分割的类别不算多特征复杂度比医学影像低。3.2 损失函数选型交叉熵、Dice Loss 还是组合损失道路分割里类别极不平衡。一张 1080p 图里道路可能占 60% 像素行人占 0.1%。直接用交叉熵模型学会“全部预测为道路”mIoU 里行人类别是 0整体指标看上去还不错但你想要的目标一个都看不见。所以损失函数必须针对小类别做处理。我有两个常用的组合第一个是CrossEntropyLoss加上类别权重权重按像素频率的倒数取对数平滑。PyTorch 里直接用torch.nn.CrossEntropyLoss(weight...)。权重计算代码import torch import numpy as np def compute_class_weight(mask_folder, num_classes): # 统计所有掩码中每个类别的像素数 pixel_counts np.zeros(num_classes, dtypenp.float64) for mask_path in mask_folder: mask np.array(Image.open(mask_path)) for cid in range(num_classes): pixel_counts[cid] np.sum(mask cid) # 权重 像素比例的倒数再取 log 平滑防止极端权重 total pixel_counts.sum() freq pixel_counts / total weight 1.0 / np.log(1.02 freq) # 1.02 是平滑项 # 归一化到 [0.5, 10] 区间避免权重过大影响优化 weight np.clip(weight, 0.5, 10.0) return torch.tensor(weight, dtypetorch.float32)这个平滑项1.02很关键。如果直接用1/freq行人这类频率 0.001 的类别权重会是 1000梯度会冲爆取 log 之后权重大概在 5~6 左右能让模型多学几次小类别但不至于让大类别完全学不到。第二个是Dice Loss和CrossEntropy的组合比例通常是 1:1。Dice Loss 直接优化 IoU 指标的近似值对小目标更友好。但纯 Dice Loss 训练初期梯度不稳定因为小目标预测错误时dice 值变化剧烈。我一般用这个函数def combined_loss(pred, target, ce_weight): ce torch.nn.CrossEntropyLoss(weightce_weight)(pred, target) # softmax 拿到概率 prob torch.softmax(pred, dim1) # 对每个类别算 dice然后平均忽略背景类可选 dice 0.0 for cid in range(1, pred.shape[1]): # 从 1 开始跳过背景 p prob[:, cid, ...] t (target cid).float() smooth 1.0 intersection (p * t).sum() dice (2.0 * intersection smooth) / (p.sum() t.sum() smooth) dice_loss 1.0 - dice / (pred.shape[1] - 1) return ce dice_loss注意这里计算 dice 用的是 softmax 概率而不是 argmax 后的硬标签这样梯度可以回传。跳过背景类别是常见操作因为背景占比太大dice 会很高稀释了小类别的损失。如果背景占比 80%算进去背景 dice 可能到 0.95整体损失看起来很漂亮但前景一塌糊涂。3.3 训练参数batch size、学习率和早停的经验值U-Net 训练我有一个经验参数表适合 512x512 输入、单卡 12GB 显存的情况参数推荐值说明batch size8单卡 12G 上限小于 4 时批归一化不稳定效果明显变差初始学习率1e-3Adam 常用SGD 建议降到 1e-2学习率调度Cosine Annealing比阶梯下降稳我试过 ReduceLROnPlateau容易在局部极小值出不来权重衰减1e-4防止过拟合特别是小数据集训练轮数80~120更看验证集 mIoU 是否连续 10 轮不涨提前停优化器AdamW比 Adam 的权重衰减实现更规范batch size 是最容易踩的。很多人用小显存卡硬上 2结果模型怎么训都发散。批归一化层在 batch 太小时均值方差估计不准推理时用的是滑动平均训练和推理行为不一致。如果必须用 batch2可以把所有 BN 层换成GroupNorm组数 8或者冻结 BN 用SyncBN在单卡上模拟。另一个常见问题是学习率Adam 的默认 lr1e-3 通常没问题但如果你在 ImageNet 预训练权重上做微调1e-3 太大会直接把预训练特征冲掉我一般微调用 1e-4从头训练才用 1e-3。早停不能只看 loss。分割任务的 loss 下降曲线很“骗人”有时 loss 在降但 mIoU 不涨尤其是加了类别权重后loss 被小类别主导。我的做法是每个 epoch 结束算验证集 mIoU记录最佳权重并设一个 patience15——连续 15 个 epoch 没有新最佳就停。这个数字不是越大越好道路场景我试过 patience30纯粹浪费时间而且权重会过拟合到验证集的天气条件换一组夜间测试图反而掉点。4. 推理优化与后处理从模型输出到能用的分割图4.1 滑动窗口推理大图显存不够时的拼接策略训练时我们用 512x512 裁剪推理时却要处理整张 1920x1080 或者全景图。直接把大图缩到 512 会丢失大量细节车道线会糊在一起。标准做法是滑动窗口裁剪然后拼接。但拼接有几个容易被忽略的点窗口边缘的预测置信度比中心低重叠区域直接取平均会造成边缘重影。我的做法是重叠取 256也就是窗口步长 窗口大小 - 重叠。拼接时用高斯权重靠近窗口中心的像素权重高边缘权重低import numpy as np import torch import torch.nn.functional as F def sliding_window_infer(model, image, window_size512, overlap256, num_classes8): # image: (1, 3, H, W) 的归一化张量 _, _, h, w image.shape step window_size - overlap # 计算需要滑动的次数最后一块如果越界做 padding rows (h - window_size) // step 1 cols (w - window_size) // step 1 output torch.zeros((1, num_classes, h, w)) weight_map torch.zeros((1, 1, h, w)) # 预生成高斯核中心 1边缘 0.5 gaussian torch.tensor(np.outer( np.hanning(window_size), np.hanning(window_size) )).float() for i in range(rows): for j in range(cols): y0 i * step x0 j * step patch image[:, :, y0:y0window_size, x0:x0window_size] with torch.no_grad(): pred torch.softmax(model(patch), dim1) # 累加预测值和高斯权重 output[:, :, y0:y0window_size, x0:x0window_size] pred * gaussian weight_map[:, :, y0:y0window_size, x0:x0window_size] gaussian # 防止除零如果某区域没有被覆盖直接置 0 output output / weight_map.clamp(min1e-6) return torch.argmax(output, dim1)这个代码里np.hanning(window_size)生成的是余弦窗中间高两边低。为什么用高斯而不是平均因为重叠区域拼接时相邻两个 patch 的边缘预测通常不可靠平均法会让边缘的“不确定预测”和中心的“确定预测”参与同样权重导致拼接处出现条状伪影。高斯加权还能顺带把窗口边界变成平滑过渡。最后argmax得到的索引就是类别 ID转成可视化颜色时注意背景类 0 要设成黑色否则整图一片彩色很难看。如果显存实在放不下 512 窗口可以把window_size降到 384但要注意 U-Net 结构中的下采样次数——如果模型有 4 次下采样窗口尺寸必须是 16 的倍数512 正好是 16 的整数倍否则会尺寸不匹配。另一个更实用的方案是半精度推理model model.half() # 或 model.to(memory_formattorch.channels_last)channels_last内存布局在 RTX 显卡上能提升速度但有些算子是 CPU 实现会拖慢。半精度推理误差极小但个别 PyTorch 版本里argmax在 fp16 下有精度问题我一般会在 argmax 之前转回 fp32。4.2 后处理连通域过滤与 CRF 的取舍模型输出的分割图常常有“椒盐噪声”——一个 200 像素的区域里零星几个像素被分成另一类。最简单的处理是形态学开运算加连通域面积过滤。我的代码import cv2 import numpy as np def postprocess(mask, min_area100, kernel_size3): # mask: (H, W) 的 uint8 类别图 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size)) # 开运算先腐蚀后膨胀去除孤立小点 opened cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 对每个类别提取连通域去掉小于 min_area 的 out np.zeros_like(mask) for cid in np.unique(opened): comp np.uint8(opened cid) num, labels, stats, _ cv2.connectedComponentsWithStats(comp, connectivity8) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] min_area: out[labels i] cid return outmin_area的取值要看图像分辨率。1080p 图上行人区域大概 500~2000 像素车道线一小段可能只有 50 像素。如果min_area设 100会把远处的车道线全删掉设 10噪声滤不干净。最好的做法是分尺度对靠近图像底部的区域用小面积阈值对远处图像上半部分用大面积阈值因为远处目标本身像素少。CRF条件随机场很多论文里会说有效但我实测在道路场景里收益不大而且推理时间暴增。CRF 适合医学影像那种边界极度不规则的自然道路图像的纹理噪声用开运算就够了。如果你追求极致的边缘平滑可以试一下opencv-contrib里的ximgproc.segmentation但那个依赖版本复杂部署环境里容易出问题不建议作为默认方案。4.3 类别平衡的另一层推理时的先验约束道路分割有一个独特的地方某些类别在空间位置上有强先验。比如天空永远在图像上半部分路面永远在下半部分。如果模型在图像顶部预测出一大片“道路”那大概率是错的。我见过有人直接按高度硬切但这样太粗暴——坡道、桥梁会伪影。更稳妥的是在推理时对高度位置加一个柔性的先验惩罚把高度低于 1/3 的区域预测为“道路”的概率乘一个小系数比如 0.9。我在实际项目里靠这个修掉了一些夜间路灯把天空照成路面的误判但谨慎使用如果你在高速上坡道会把这个先验破坏。后处理这块我的总体原则是“越少越好”。很多工程师一个劲往管线里加后处理每加一层就延迟几十毫秒边际收益越来越小。先把模型训练好后处理只做最小必要项连通域过滤 类别 ID 到色彩的映射表。5. 避坑指南道路分割训练和部署中遇到的 5 个高频坑5.1 掩码文件格式是 BGR 还是 RGB颜色映射混乱导致类别错位现象训练 loss 不降或者 val mIoU 一直在 0.1 左右可视化分割结果发现类别颜色全乱路是红的车是绿的。 原因Labelme 导出的 JSON 里imagePath对应的原图如果是 BGR 保存掩码用cv2.imwrite写 PNG 时会按 BGR 通道写但读掩码时如果用PIL.Image.open以 RGB 方式读通道顺序不同灰度图没有这个问题——问题出在当掩码是彩色 PNG 时。 解决统一用灰度 PNG 存掩码。cv2.imwrite(..., mask)时 mask 是单通道 uint8直接写。读的时候也只用cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)。如果项目里已有彩色掩码转换时用cv2.cvtColor(mask_bgr, cv2.COLOR_BGR2GRAY)并查一下灰度值是否等于类别 ID。5.2 类别 ID 为 0 的背景在损失里权重太大现象模型几乎把所有像素预测为背景前景目标偶尔闪一下。 原因CrossEntropyLoss 的默认权重每个类别是 1.0背景像素占总数的 70% 以上梯度被背景主导。前面讲的compute_class_weight里我特意把背景类的权重压到最低但很多人直接套用忘了改平滑项。 解决如果背景占比超过 60%应该把背景类的权重固定为 0.5 或 0.3其他类别按频率倒数归一化。或者训练时从第 50 个 epoch 起停止对背景计算梯度因为前期模型已经能分背景了后期再学背景会妨碍精细边界收敛。5.3 夜间数据集亮度增强玩过头模型学坏现象白天测试效果不错夜间测试崩溃或者出现“紫色天空”这种诡异预测。 原因数据增强里的亮度/对比度抖动范围过大把白天的图像增强成了“伪夜”但纹理和噪声特性跟真实夜间差太远。模型学到的是“暗就是夜”而不是真实夜间的特征。 解决亮度抖动因子控制在 ±0.15 以内并且额外收集真实夜间数据配合 MixUp 或 CutMix 和白天数据混合。千万不要靠纯亮度增强代替真实夜间数据。5.4 滑动窗口推理时显存碎片导致 OOM现象训练没问题推理时跑滑动窗口开始时能跑跑到第 50 个 patch 直接 OOM。 原因PyTorch 的显存分配器在循环里反复申请释放内存碎片越来越多。每个 patch 虽然小但峰值碎片累积后无法分配新的块。 解决推理前先torch.cuda.empty_cache()把模型输出的预测叠加到 CPU 张量上减少 GPU 上长期驻留的变量。另一种更彻底的做法是把窗口循环放进with torch.no_grad():并且每个 patch 结束后del pred让显存及时释放。如果还 OOM就把overlap减小从 256 降到 128虽然拼接质量略降但显存压力减半。5.5 验证集和测试集来自同一段视频评估指标虚高现象训练 mIoU 0.82验证 mIoU 0.80心里美滋滋部署到新路段mIoU 掉到 0.55。 原因数据按“从视频按帧抽”的方式划分训练/验证同一个路段的相邻帧太像了验证集和训练集数据分布几乎一样。 解决按“路段”划分。把不同日期、不同路段作为划分单位同一路段的帧全部归入同一个集合。训练集和验证集的道路类型要有区别比如训练集用城市道路验证集用乡村道路或高速。这个问题的另一种体现是模型记住了特定十字路口的路面纹理换个路口就翻车。6. 进阶把 U-Net 从“跑通”变成“好用”的三个手法第一个手法是改用带预训练编码器的 U-Net比如segmentation_models_pytorch库里的Unet(encoder_nameresnet34, encoder_weightsimagenet)。我原先自建编码器从头训练 100 个 epoch 的 mIoU 只有 0.72换成 ResNet34 预训练编码器同数据前提下 60 个 epoch 就到 0.78。预训练编码器的钱省在优化速度上很多人以为用预训练会过拟合但分割任务里 encoder 部分学习的是边缘纹理通用特征不会轻易过拟合到特定道路。唯一要改的是输入通道如果相机是灰度图把预训练的第一个卷积层权重对 RGB 三个通道求平均复制成单通道而不是随机初始化。第二个手法是验证指标除了 mIoU还要单独看“边界精度”。道路场景里路缘石和车道线这些结构mIoU 高不一定代表边界贴合。我常用Boundary IoU或者直接算“预测掩码与真值掩码在边缘膨胀 3 像素区域内的 IoU”。这个指标只要写几行from scipy.ndimage import binary_dilation def boundary_iou(pred, gt, num_classes, radius3): boundary_ious [] for cid in range(num_classes): p pred cid g gt cid if g.sum() 0 and p.sum() 0: continue g_b binary_dilation(g, iterationsradius) p_b binary_dilation(p, iterationsradius) inter (g_b p_b).sum() union (g_b | p_b).sum() boundary_ious.append(inter / max(union, 1)) return np.mean(boundary_ious)我对训练好的模型跑这个指标发现很多模型 mIoU 差不多但边界 IoU 差 5 个百分点。边界差的车道线在投影到鸟瞰图后会扭曲直接影响下游路径规划。如果你的场景非常看重边界车道线、路缘石可以用Boundary Loss它通过距离图加权交叉熵让模型更关注边界附近的像素。第三个手法是轻量化部署。U-Net 在 Jetson 或车载工控机上跑一个标准 U-Net 输入 512x512大概要 30~60 GFLOPS。剪枝和量化两件事见效最快先做通道剪枝把编码器里贡献小的卷积通道去掉再做 INT8 量化用 TensorRT 或者 ONNX Runtime 的静态量化。量化最怕的是跳跃连接拼接后的数值范围变化我踩过坑skip connection 的输出范围超出量化范围结果边缘全裂。解决方法是量化时对跳跃连接之后的激活单独标定或者只量化编码器部分解码器保持 FP16。另一个轻量选择是把编码器换成 MobileNetV3mIoU 会掉 2~3 个点但速度能快 3 倍。项目时间不充裕时我更推荐先试 MobileNetV3 编码器 U-Net 解码器因为预训练权重容易搞到部署生态也更成熟。最后收个经验U-Net 做道路分割模型结构本身不是瓶颈数据划分、标签一致性和后处理之间的配合才是。我见过太多人天天调损失函数但连验证集划分都错了。拿到一个新的道路数据集我会先花一半时间把掩码可视化检查一遍——把预测图叠加到原图上半透明显示看边缘对齐不准的还是类别错乱的。这一步比调参更能发现问题。希望这些踩坑和调试思路能让你少走几次弯路把道路分割系统真正用起来。本文还有配套的精品资源点击获取