简介:面向计算机视觉与深度学习初学者,提供街景图像语义解析的完整项目源码,可用于道路、建筑、车辆、行人等类别的像素级分割研究。实现上采用 DenseASPP、MobileNetDenseASPP 等网络,覆盖 FCN、U-Net、SegNet 常见结构,并给出数据增强、损失函数设计、分割结果优化等关键处理;同时提供 DenseASPP121、DenseASPP161、DenseASPP169、DenseASPP201 等多种变体实现。压缩包内共23个文件,以 Python 脚本为主,辅以缓存文件、备份配置、说明文档及备用压缩包,整体仅18KB,便于快速阅读。目前已有52人学习使用。源码包含推理、迁移、演示等脚本,以及 models、cfgs 等目录,结构清晰,配合 README 说明,适合希望结合代码理解语义分割原理、掌握深度学习工程实现或用于街景识别方案参考的开发者。
1. 从“看图”到“看懂图”:为什么街景图像需要语义解析
一张街景照片,人眼扫过去能立刻说出“左边是店铺招牌,右边是行道树,前方是斑马线,远处有红绿灯”,但计算机看到的只是一堆像素矩阵。街景图像的语义解析,就是把像素逐个映射到预定义类别(建筑、道路、车辆、行人、天空等)的过程,本质上是语义分割(Semantic Segmentation)在街景场景下的具体落地。它和普通图像分类最大的区别在于:分类只回答“这张图里有什么”,语义解析要回答“每个像素是什么”。这个差异决定了系统的设计复杂度——不是训练一个网络就完事,而是涉及数据标注口径、模型选型、推理性能、部署形态一整条链路。
街景场景比通用分割更棘手:光照从正午到夜晚跨度极大,视角多变,交通参与者和背景建筑物的尺度差异悬殊,而且街景图像通常由车载相机连续采集,单帧处理速度直接决定后续是否有工程价值。无论你是要做自动驾驶感知、数字孪生城市建模,还是做城市管理中的违章识别,都绕不开“像素级解析”这道工序。对于那些刚接触这个课题的工程师来说,最容易犯的错是拿到 Cityscapes 数据集就开训 DeepLabV3+,等精度够了才发现推理速度完全达不到要求。这篇文章会从系统设计的高度把整条链路拆开:选什么模型、用什么框架、如何做工程化封装、训练和推理的坑在哪里,以及拿到一份开源源码时应该从哪几个文件开始读起。
2. 语义解析系统的模型选型与损失函数设计
2.1 先定基线:语义分割模型的分水岭在哪里
语义分割模型发展了数代,从最早的 FCN 全卷积网络,到 U-Net 的编码器-解码器结构,再到 DeepLab 系列的空洞卷积(Dilated/Atrous Convolution),每一步演进解决的核心问题都不同。FCN 解决了“全连接层丢失空间信息”的问题,U-Net 用跳连保住边缘细节,DeepLab 系列则用不同膨胀率的空洞卷积在不降低分辨率的前提下扩大感受野。
街景解析系统的起点,我建议直接锁定在 DeepLabV3+ 或 U-Net 这两个成熟结构上,而不是一上来就追最新的 Transformer 分割模型。理由是:街景数据集的标签分布极度不均衡——天空、道路、建筑占据了大量像素,而交通标志、摩托车、骑行者这类小目标占比极小。先进的视觉 Transformer(如 SegFormer)在 Cityscapes 这类基准上 mIoU 确实更高,但对显存的消耗和推理延迟在工程初期是不可控变量。先拿成熟 CNN 模型把整套系统跑通,再决定是否需要上大模型,这是更稳妥的路线。
DeepLabV3+ 的结构可以拆成三块:骨干网络(通常用 ResNet101 或 MobileNetV2)、ASPP(Atrous Spatial Pyramid Pooling)模块和 Decoder。ASPP 的核心思想是并联多个不同膨胀率的空洞卷积,让同一层特征能同时捕捉小物体和大物体的上下文;Decoder 则把 ASPP 输出的高层语义特征与骨干网络下采样前的低层细节特征融合,恢复被多次池化抹掉的边缘信息。说直白点,ASPP 管“这一块到底是什么”,Decoder 管“边界到底在哪画”。
U-Net 的优势则是结构简单、对小数据集友好。它的编码器和解码器完全对称,跳连把每一层的特征图直接拼接到对应的解码层,即使训练样本只有几百张,也能收敛得不错。如果你的课题数据是自采街景而非 Cityscapes,U-Net 是比 DeepLabV3+ 更保险的起点。
2.2 损失函数:不能只用一个 CrossEntropyLoss
街景语义解析的标签分布极度倾斜,直接跑交叉熵会造成模型“只学大类别、忽略小类别”的偏置。一个好的系统设计,在损失函数环节就要把这个问题考虑进去。
第一层防线是加权交叉熵,可以用中位频率平衡(Median Frequency Balancing)算出每个类别的权重,给出现频率低的类别更大的损失系数。公式如下:
[ w_c = \frac{\text{median_freq}}{freq_c} ]
其中 (freq_c) 是类别 c 的像素频率,median_freq 是所有类别频率的中位数。简单说,出现越少的类,权重越高。这个策略不增加任何计算开销,只是对 Loss 乘一个系数。
第二层防线是 Dice Loss,它的设计初衷就是解决前景背景不平衡问题,按区域而不是按像素计算重合度。实践中我一般把 CrossEntropy 和 Dice 按 7:3 或 8:2 的比例加权相加。纯用 Dice 在训练初期梯度不稳容易崩,混合损失是更稳的工程选择。
第三层防线是边界的显式约束,街景中杆状物(路灯、电线杆、交通标志立柱)和道路边界被粘连是最常见的错误。如果有精力,可以叠加一个边界感知分支,在 Decoder 输出处额外预测边界 mask,把边界预测的损失与主分割损失相加。这个做法的本质是让网络在训练时对“哪里是分割边界”有显式监督,而不是全靠特征隐式学习。
import torch import torch.nn as nn import torch.nn.functional as F class MixSegLoss(nn.Module): def __init__(self, class_weights=None, ce_weight=0.7, dice_weight=0.3): super().__init__() # class_weights 通过中位频率平衡预先算好,形状为 [num_classes] self.ce = nn.CrossEntropyLoss(weight=class_weights) self.ce_weight = ce_weight self.dice_weight = dice_weight def forward(self, logits, targets): # logits: [B, C, H, W],targets: [B, H, W] 且值为类别索引 ce_loss = self.ce(logits, targets) dice_loss = self.compute_dice(logits, targets) return self.ce_weight * ce_loss + self.dice_weight * dice_loss def compute_dice(self, logits, targets, smooth=1.0): probs = F.softmax(logits, dim=1) # [B, C, H, W] targets_onehot = F.one_hot(targets, num_classes=probs.shape[1]) targets_onehot = targets_onehot.permute(0, 3, 1, 2).float() # [B, C, H, W] intersection = (probs * targets_onehot).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets_onehot.sum(dim=(2, 3)) dice = (2.0 * intersection + smooth) / (union + smooth) return 1.0 - dice.mean()这段代码把两类损失组合成一个模块。注意compute_dice中的smooth是一个极小的常数,防止分母为 0;one_hot要求targets的值不能出现超出num_classes的索引,否则会报错。参数ce_weight和dice_weight是可调的,如果发现小类别被忽略,就把dice_weight调高到 0.4 左右,但不要超过 0.5,否则训练初期的波动会让 loss 震荡得很厉害。
2.3 评价指标:mIoU 与类别 IoU 分开看
系统设计阶段就要明确,最终拿什么指标说明“我比基线好”。语义分割领域默认的指标是 mIoU(mean Intersection over Union),计算方式是先求每个类别的 IoU,再对所有类别取平均。但如果只看 mIoU,很容易被“天空”、“建筑”这类大类别的高 IoU 掩盖了“骑行者”、“摩托车”这类小类别的糟糕表现。
所以在评估模块里,我习惯同时输出每个类别的 IoU、整体 mIoU 和 Pixel Accuracy,并且在小类别的 IoU 上单独做记录。比如 Cityscapes 的类别分为 flat(道路、人行道)、nature(植被)、object(车、自行车)等七组,调试时看组内小类的涨跌比看总 mIoU 更有指导意义。
3. 从源码角度看街景语义解析系统的工程化设计
3.1 源码里最先该看哪几个文件
拿到一个标注“源码分析”的街景语义解析项目,直接从头到尾读代码是大忌。源码的核心价值不在于每行代码都写得精巧,而在于作者对系统边界和数据流的切分方式。我一般按以下顺序读:
数据加载模块是第一个要看的文件,重点在于它如何处理图片和标签的对应关系、是否做了数据增强、是否处理了类别不平衡。标注来源多种多样,有 Cityscapes 的 json 格式,有 Mask R-CNN 风格的多边形 RLE 编码,有直接 BMP 逐像素标注。数据加载器如果写得乱,后续所有训练流程都会受影响。
第二个要看的是网络结构定义文件。读的关键点是:骨干网络的输出 stride 是多少,ASPP 的膨胀率配比,Decoder 阶段低层特征的通道数怎么对齐。这些参数会影响显存占用和感受野设计,不是随便抄作业就行。
第三个要看配置管理模块。好的系统会把模型结构参数、训练超参数、数据路径、类别列表全部集中在 yaml 或 json 配置里,而不是散落在各个训练脚本中。如果源码里超参数是硬编码在 train.py 里的,这个项目的工程化程度就要打个问号。
下面是一段常见的类别加载和配置管理的结构示例:
# configs/cityscapes.yaml 的语义示意 dataset: name: cityscapes root: /data/cityscapes num_classes: 19 ignore_index: 255 model: name: deeplabv3plus backbone: resnet101 output_stride: 16 aspp_rates: [6, 12, 18] train: batch_size: 8 base_lr: 0.007 momentum: 0.9 weight_decay: 0.0005 epochs: 300 lr_schedule: poly这段配置里的ignore_index: 255是语义分割任务的一个重要细节。街景数据集中很多像素没有被标注,或者属于车辆内部、人物本身这类“忽略类别”,训练时要手动把它排除在损失计算之外。PyTorch 的CrossEntropyLoss自带ignore_index参数,如果你的源码里没有设置,训练出的模型会在未标注区域输出不可控的预测结果。
3.2 数据管线设计:共享内存和图片大小是性能瓶颈
街景图像通常是大尺寸高分辨率图片,Cityscapes 原始图像是 2048×1024,如果直接以完整分辨率送入网络,显存直接爆掉。常见的处理方式有两种:随机裁剪固定 patch,如 512×1024,或先缩放再裁剪。两种路线对模型最终效果的影响不同——大面积缩放会丢失小目标的细节,但训练速度快;裁剪保留原始分辨率,但上下文信息可能不足。
在数据管线的工程实现上,PyTorch 的DataLoader有几个关键参数值得注意:
num_workers:加载图片的进程数。街景图像解码本身是 CPU 密集任务,设成 CPU 核心数的 50%~75% 性价比最高prefetch_factor:每个 worker 预取的批次数,常设 2-4,增加显存换训练吞吐pin_memory:设为 True。当训练在 GPU 上进行时,锁页内存能减少 CPU 到 GPU 的拷贝时间,十分关键persistent_workers:设为 True。避免每个 epoch 结束时销毁重建 worker,对小数据集尤其明显
from torch.utils.data import DataLoader from torchvision import transforms train_loader = DataLoader( dataset=train_dataset, batch_size=8, shuffle=True, num_workers=12, # 看机器 CPU 核心数 pin_memory=True, drop_last=True, # 丢弃最后不足 batch_size 的 batch,稳定 BN 统计 prefetch_factor=4 )使用进程池加载数据时需要留意,若num_workers设得过高,反而会在进程切换和数据搬运上消耗大量 CPU 时间;而设得过低又会让 GPU 处于“等数据”的空闲状态。经验判断标准是:如果 nvidia-smi 显示 GPU 利用率和显存利用都正常,但是 train loss 曲线比其他人的复现慢很多,优先检查num_workers是不是太小,或者数据增强使用了transforms.Resize导致所有进程都在等比缩放大图而卡住。
3.3 训练循环里的三个隐藏工程点
第一个是学习率策略。街景分割任务的标配是 poly 衰减,即初始学习率乘以 ((1 - \frac{iter}{total_iters})^{power}),power 通常取 0.9。这和分类任务里常用的 StepLR 或 CosineAnnealing 不一样——分割任务由于训练轮数长、学习率整体平稳下降,poly 策略能同时在前期学到细节、末期稳定收敛。如果你的源码里用的是固定学习率,基本可以断定效果不会太好。
第二个是 BN 层的统计量同步。如果用了多卡训练,nn.BatchNorm2d默认只统计当前卡上的数据,这会严重损害准确率。多卡环境必须用SyncBatchNorm,并且把模型在 DataParallel 或 DistributedDataParallel 之前先转换:
if args.distributed and torch.cuda.device_count() > 1: model = nn.SyncBatchNorm.convert_sync_batchnorm(model)这里要注意的是,convert_sync_batchnorm必须在包装 DDP 之前调用,否则 BN 不会被替换,转换会静默失效。
第三个是数据增强的乱序问题。街景场景下,水平翻转、随机缩放和颜色抖动是效果最明显的三个增强。颜色抖动是因为不同城市、不同季节的街景色调差异很大,增强能提升泛化性。需要小心的是,数据增强不能乱序到标签与原始图像尺寸不匹配。常规组合是:先做随机缩放,再随机裁剪出固定 patch,再水平翻转,最后做颜色抖动,整个过程对标签和图像施加完全相同的随机变换。
4. 街景语义解析系统的推理部署与性能优化
4.1 从 PyTorch 模型到 TensorRT 加速
训练完的模型要真正用于街景视频流的实时解析,就要做推理优化。常见做法是把 PyTorch 模型导出为 ONNX,再转 TensorRT 引擎。导出 ONNX 时要注意的坑集中在opset_version和动态输入维度上:
import torch model.eval() dummy = torch.randn(1, 3, 1024, 2048).cuda() torch.onnx.export( model, dummy, "deeplabv3plus.onnx", opset_version=11, do_constant_folding=True, input_names=["input"], output_names=["output"], dynamic_axes={ "input": {0: "batch_size", 2: "height", 3: "width"}, "output": {0: "batch_size", 2: "height", 3: "width"} } )解析这段参数:opset_version=11是为了兼容公司的推理环境,如果你的 TensorRT 版本较旧,可能需要降到 9 或 10 才能通过解析;dynamic_axes指定了 batch、高、宽三个维度可以动态变化,这是因为街景服务在真实调用时,请求的视频分辨率不固定。导出后建议用onnxruntime或 TensorRT 的trtexec工具做一次精度比对,确认输出和 PyTorch 原模型在数值上只存在微小浮点误差,而不是完全变化。
4.2 推理时图像预处理的逆变换
部署阶段的隐性坑藏在图像归一化与结果可视化的转换上。训练时用的 normalization 往往是零均值、单位方差的 ImageNet 统计量,推理时如果用了 OpenCV 的 BGR 格式读图,又直接送进为 RGB 设计的网络,色彩通道就会乱序,导致 mIoU 骤降。
一个安全的推理预处理链是:
import cv2 import numpy as np def preprocess(image_bgr, mean=(123.675, 116.28, 103.53), std=(58.395, 57.12, 57.395)): # 默认使用 ImageNet 统计,注意输入是 BGR img = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) img = img.astype(np.float32) img = (img - np.array(mean)) / np.array(std) # CHW 与 batch 维度 img = img.transpose(2, 0, 1)[None, ...] return torch.from_numpy(img).cuda()这里的mean和std的三通道顺序是 BGR 还是 RGB,取决于训练脚本里的ToTensor之前有没有transforms.RGB转换。如果训练时用的是 OpenCV 读图再送进网络,那么推理时也必须保持一致,混合使用是最常见的部署事故源。
4.3 滑窗推理与大图内存控制
车载相机输出的街景分辨率往往比 Cityscapes 基准的 2048×1024 还要大,比如 3840×2160。这种分辨率无法一次性送入显存有限的推理卡。常见方案是滑窗推理:把大图切分成重叠的小块,分别推理后拼接。重叠区域越多,拼缝的伪影越少,但计算量越大。
滑窗推理需要重点处理的不是“怎么切”,而是“怎么拼”。如果在重叠区直接取均值,会导致边界亮度突变。较好的解决方案是让每块的权重从中心向边缘线性衰减,按权重加权融合重叠部分的预测:
def sliding_window_predict(model, image, tile_size=512, overlap=64): h, w = image.shape[2:] stride = tile_size - overlap prob_map = np.zeros((num_classes, h, w), dtype=np.float32) weight_map = np.zeros((h, w), dtype=np.float32) for y in range(0, h - tile_size + 1, stride): for x in range(0, w - tile_size + 1, stride): tile = image[:, :, y:y+tile_size, x:x+tile_size] with torch.no_grad(): logits = model(tile) prob = torch.softmax(logits, dim=1).cpu().numpy()[0] # 线性衰减权重 weight = get_ramp_weight(tile_size) prob_map[:, y:y+tile_size, x:x+tile_size] += prob * weight weight_map[y:y+tile_size, x:x+tile_size] += weight prob_map /= np.maximum(weight_map, 1e-6) return prob_map.argmax(axis=0)get_ramp_weight生成一个从块中心向边缘线性递减到 0.3 倍左右的权重矩阵。该方案的代价是计算量几乎翻倍,但能显著降低边缘接缝。在工程上能否接受,取决于最终产品对视觉效果的要求。
5. 街景语义解析的训练避坑:类别不均衡、标签噪声与显存优化
5.1 类别不均衡的排查路径:从混淆矩阵看问题
训练完成后,如果发现 mIoU 卡在某个平台期上不去,第一步不是盲目调骨干网络或换损失函数,而是打印出 19×19 的混淆矩阵。重点看哪些类别互相混淆。街景场景常见的有三组问题:路灯杆和电线杆互相混,行道树和灌木丛边界互相吞并,道路和路缘石分不开。这些错分的根因各不相同,不能靠一个统一手段解决。
第一组问题是小目标类别样本太少,解决思路包括裁剪出包含杆状物的区域做二次训练,或使用 Mixup 增强。第二组和第三组问题更多是标签标注本身精度不够,也就是标签噪声。街景标注往往用多边形标注工具,边界处的像素归属本身就存在人为不确定性,模型学到的边界自然就模糊。
5.2 OHEM:在线困难样本挖掘
另一个处理类别不均衡的高阶手段是 OHEM(Online Hard Example Mining)。做法是前向计算出每个像素的交叉熵损失,把损失值排序,只取 top-k 的像素回传梯度。这个策略能强制模型专注于训练困难的像素,往往是提升杆状物 IoU 最有效的手段之一。但要注意的是 OHEM 计算量翻倍(需要先完整前向一次拿到 loss),显存和训练时间都要多算一份。
5.3 GPU 显存不足时的四种应对手段
街景图像的语义分割对显存的要求远超分类任务。训练时如果把 2048×1024 的原图直接输入,显存 24G 都有点紧。常见的解决顺序是:先尝试梯度累积(gradient accumulation),把显存需求平移为时间需求;再尝试降低 batch size 同时调高学习率分母;如果还不够,就降低输入分辨率,但要注意 mIoU 会损失 1~2 个点;最后才是换轻量骨干网络如 MobileNetV2。
scaler = torch.cuda.amp.GradScaler() for batch_idx, (imgs, masks) in enumerate(train_loader): with torch.amp.autocast(device_type='cuda'): logits = model(imgs) loss = criterion(logits, masks) loss = loss / accum_steps scaler.scale(loss).backward() if (batch_idx + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()注意loss = loss / accum_steps这行不能省。原因在于:梯度累积只是把多批次的梯度加在一起,如果不除以累积步数,等效的batch_size被放大数倍,学习率没有同步调整会导致训练不稳定。混合精度训练(AMP)在这套流程里是标配,注意先用torch.cuda.amp.autocast包住模型的前向传播和损失计算,但F.one_hot和交叉熵在 FP16 下的溢出风险要留心,必要时把损失函数内部的targets强制转成torch.long。
6. 用 Grad-CAM 和预测可视化判断模型学到了什么
语义分割模型的调试不能只靠 mIoU 一个数字。我的习惯是训练结束后,立刻跑一个可视化工具包,把三类图拼在一起:原图、预测结果、真实标签。着重要看的是“模型预测错得有没有道理”,比如把天空预测成道路边界是不可饶恕的全局错误,但把灌木丛预测成树木属于局部混淆,严重性不同。
更进一步的诊断工具是 Grad-CAM,它用梯度信息计算模型对输入空间位置的关注度。尽管 Grad-CAM 多用在分类任务上,对分割任务同样有诊断价值——把梯度从分割头的输出反传到骨干网络的特征图,再上采样到原图尺寸,叠加热力图就能看出模型是依据哪些区域做出判断的。如果发现一次路灯杆的预测完全依赖了地面阴影纹理,说明模型学到的是背景关联而非杆状物本身,那就要在数据增强里加亮度扰动或更细致的裁剪。
最终的工程落地阶段,可以考虑把整套系统封装成带边界优化后处理的推理服务:先用语义解析模型输出 19 类的像素级标签,再做连通域分析和形态学开闭运算删除微小噪点,最后把分割结果叠回原图以半透明方式输出。这套后处理不改变模型权重,但对视觉观感提升极大,在项目答辩或产品演示时收益非常明显。街景语义解析的价值,就在这一层一层从像素到语义、从模型到系统的推进中被真正激发出来。
本文还有配套的精品资源,点击获取