简介一套面向毕业设计与课程作业的语义分割工程代码包聚焦深度学习在室内场景解析中的应用适合计算机视觉方向学生或需要快速搭建分割系统的开发者。资源整合Python与C混合开发思路覆盖数据准备、模型训练、测试评估到工程部署的完整链路压缩包共24个文件以Python脚本为主约占10个承担数据加载器、模型定义与训练流程另有XML配置、TXT说明、PNG结果图以及工程标识文件整体仅820KB结构紧凑。目前已有157人学习下载。包内代码实现了可用的分割网络训练与测试框架包括点云/场景数据预处理工具、关键模型定义、多阶段训练脚本与可视化结果按目录组织清晰可帮助理解语义分割的训练范式、超参数配置与工程组织方式。借助该资源读者能够快速复现分割流程并在此基础上调整优化对完成课设或毕设具有实际参考价值。1. 基于深度学习的场景语义分割不只是分类是把每个像素归到正确的类毕设开题周你大概率见过这种文件夹名毕设课程作业_基于深度学习的场景语义分割.zip。解压之后 47 个 Python 文件、三个 CSV、一个没有 README 的 checkpoints 目录大多数人到这里就卡住了。这个标题背后真正要解决的事情其实非常具体给一张街拍图让模型把每个像素标成“道路、人行道、汽车、行人、建筑”等语义类别而不是像图像分类那样只给整张图打一个标签。它属于密集预测任务常见落地架构是 FCN、UNet、DeepLab 系列常用公开数据集是 Cityscapes 和 PASCAL VOC。我复现这类项目时最深的感受是模型反而不是瓶颈数据和类别映射协议才是90% 的翻车都发生在读图、标签对齐和评估指标这三件事上。这篇文章按我自己的复现顺序写先解决“这是什么”再解决“怎么做”最后把几个最坑的细节摊开。2. 标签即协议语义分割数据集的三种标注表示和类别映射2.1 从 PASCAL VOC 到 Cityscapes标签像素值不是类别号语义分割的标准数据集各有各的“脾气”。PASCAL VOC 2012 是 21 类背景 20 个物体类Cityscapes 是 19 个评估类原始标注 34 类评估时合并成 19 类ADE20K 则是 150 类的大规模场景解析。它们的标注文件形态完全不一样我整理了一张表方便你对照数据集类别数标注文件格式原图分辨率标签分辨率PASCAL VOC 201221含背景PNG 单通道索引图像素值 0~20255 为 ignore约 500x375与原图一致Cityscapes19评估类PNG 单通道索引图像素值 0~1833 为 ignore1024x2048与原图一致gtFinegtCoarse 为 1/4 分辨率ADE20K150PNG 单通道索引图像素值 0~149平均 1500x1000与原图一致最容易被忽略的是PNG 里的像素值并不等于可读的类别名而是class_id。比如 Cityscapes 的原始标注里像素值 24 对应“道路”但评估协议要求把它映射成 id 0像素值 25 是“人行道”映射成 id 1。很多开源代码直接读原图标注就拿来训练loss 在几十个类上乱跳模型根本训不动。正确做法是准备一份trainId映射表把原始像素值转换成 0~18 的紧凑索引再把忽略区域填成ignore_index也就是代码里常用的 255。还有一个对比度鲜明的细节VOC 的掩码图肉眼看起来是黑色的只有用颜色映射工具才能看清不同区域。那是因为类别索引没有直接落成 RGB 值。Cityscapes 的标注图提供了彩色可视化版本很多初学者误以为那个彩色 PNG 就是训练标签实际训练应该用单通道的labelTrainId图。判断方法很简单用 PIL 打开标注图如果mode是P或I说明是单通道索引图如果mode是RGB多半是可视化版本不能直接训练。2.2 三种标注表示索引图、one-hot 张量与可视化图在代码层面同一个语义分割标签有三种形态互相转换是基本功也是新手最容易搞混的地方。第一种是单通道索引图形状是(H, W)每个位置的数值表示类别 id第二种是one-hot 张量形状是(C, H, W)每个通道是 0/1 掩码softmax 交叉熵损失函数内部就是这么用的第三种是彩色可视化图形状是(H, W, 3)只用于给人看的展示不参与计算。我一般用下面这段代码处理 Cityscapes 原始标注import numpy as np from PIL import Image # 原始 Cityscapes 标注图是单通道索引图 mask np.array(Image.open(gtFine_labelIds.png)) # 定义从原始像素到训练类别 id 的映射-1 表示忽略区域 mapping { 7: 0, # road - 0 8: 1, # sidewalk - 1 11: 2, # building - 2 12: 3, # wall - 3 13: 4, # fence - 4 17: 5, # pole - 5 19: 6, # traffic light - 6 20: 7, # traffic sign - 7 21: 8, # vegetation - 8 22: 9, # terrain - 9 23: 10, # sky - 10 24: 11, # person - 11 25: 12, # rider - 12 26: 13, # car - 13 27: 14, # truck - 14 28: 15, # bus - 15 31: 16, # train - 16 32: 17, # motorcycle - 17 33: 18, # bicycle - 18 } train_id_mask np.full(mask.shape, 255, dtypenp.uint8) for orig_id, target_id in mapping.items(): train_id_mask[mask orig_id] target_id # 保存为紧凑索引图直接用于训练 Image.fromarray(train_id_mask, modeL).save(gtFine_trainIds.png)这段代码的关键点是np.full(mask.shape, 255, dtypenp.uint8)先填充 255再逐类覆盖。这样既保证了没有映射到的类被当成忽略区域也保留了 0~18 这些有效类。训练时CrossEntropyLoss(ignore_index255)会直接跳过这些像素避免它们在类别数之外造成梯度回传。2.3 验证指标为什么是 mIoU手动算一次就懂了语义分割通常用mIoU平均交并比评估而不是准确率。原因很直接数据集中“背景”或“道路”这类像素数量巨大模型把所有东西都预测成道路像素准确率能轻松到 90% 以上但建筑物、行人、自行车这些类别全没被识别出来。mIoU 对每个类别单独计算再取平均小类别和多数类的权重是平等的。单个类别的 IoU 计算公式很简单该类别的预测掩码与真实掩码的交集面积除以并集面积。比如“汽车”这一类的真实像素有 100 个模型预测了 120 个像素是汽车其中 80 个和真实重合那么 IoU 就是80 / (100 120 - 80) 0.5714。我习惯在验证脚本里用下面的方式手动核对避免直接依赖框架自带指标函数def compute_iou_per_class(pred, target, num_classes, ignore_index255): 逐类计算 IoU。 pred: 模型输出 argmax 后的索引图 (H, W) target: 真实标签索引图 (H, W) 返回 长度为 num_classes 的 list ious [] valid target ! ignore_index for cls in range(num_classes): pred_cls (pred cls) true_cls (target cls) valid intersection (pred_cls true_cls).sum() union (pred_cls | true_cls).sum() if union.item() 0: # 真实和预测都没有这一类约定记为 1.0不参与惩罚 ious.append(1.0) else: ious.append(intersection.item() / union.item()) return ious注意union为 0 时记为 1.0 的做法这是 mIoU 计算里的常见约定不然类别数多时没出现过的类会拉低平均分。如果你用现成库函数比如torchmetrics.JaccardIndex也要检查它的absent参数设置逻辑。3. 从预训练权重到一张分割图最小推理 demo 的环境与配置3.1 环境配置比训练更早卡住人的常常是 CUDA 和 torchvision 版本动手跑模型之前先把环境对齐。这里直接给出我反复使用的一套组合兼容性最稳定conda create -n seg python3.10 -y conda activate seg # 安装 PyTorch 2.xCUDA 11.8/12.1 按自己的驱动选CPU 机器去掉 cu118 即可 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python pillow matplotlib tqdmtorchvision比torch更容易出问题的地方是版本匹配。不同版本的torchvision可能没有内置对应版本的 DeepLabV3 预训练权重加载权重时会出现Missing key(s) in state_dict这一类报错。我的经验是如果只是跑 demo不追求最新特性用 PyTorch 2.0 配 torchvision 0.15 这一组最稳。如果你在 CPU 机器上跑注意 CUDA 版的 PyTorch 也能正常用 CPU 推理只是慢一些不需要额外装一个 CPU 版我身边有不少人在这里白白折腾了很久。3.2 用 torchvision 里的 DeepLabV3 跑通第一张分割图不需要从零搭建网络torchvision.models.segmentation.deeplabv3_resnet50就是现成的模型预训练权重在torchvision官方训练好的 COCO 语义分割数据集上效果就已经可观。下面是完整的最小推理脚本import torch import numpy as np from PIL import Image from torchvision import transforms from torchvision.models.segmentation import deeplabv3_resnet50, DeepLabV3_ResNet50_Weights # 加载预训练权重COCO 数据集上训练21 类 weights DeepLabV3_ResNet50_Weights.DEFAULT model deeplabv3_resnet50(weightsweights) model.eval() # 如果没有 GPU这行会自然回退到 CPU device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) # 预处理resize 到 520归一化参数必须和预训练一致 transform weights.transforms() img Image.open(street_scene.jpg).convert(RGB) input_tensor transform(img).unsqueeze(0).to(device) with torch.no_grad(): output model(input_tensor)[out] # shape: (1, 21, H/8, W/8) pred torch.argmax(output, dim1).squeeze(0).cpu().numpy() # 此时 pred 是 21 类索引图尺寸约 62x120需要上采样回原图 print(pred.shape)weights.transforms()是这版 API 里最关键的一步它包含了 resize、归一化、通道转换所有细节包括均值[0.485, 0.456, 0.406]和标准差[0.229, 0.224, 0.225]。很多人手写预处理时忘记 resize 或者归一化参数对不上模型输出就成了随机噪声。推理输出的是(1, 21, H/8, W/8)的张量也就是每个像素在 21 个类别上的 logits需要argmax把它变成离散类别索引。真正用于可视化的颜色映射是后处理阶段的内容和模型本身无关。3.3 上采样回原图用最近邻不要用双线性模型输出的空间分辨率只有输入的 1/8所以最后一步必须上采样回原图大小。这里有一个极容易踩的坑直接调用torch.nn.functional.interpolate时默认是双线性插值用在分类索引图上会生成中间数值比如 3.4 这种根本不存在语义的类别号。正确写法如下import torch.nn.functional as F # 对 pred 上采样回原图尺寸modenearest 是语义分割索引图的标准做法 pred_up F.interpolate( torch.from_numpy(pred).unsqueeze(0).unsqueeze(0).float(), size(img.height, img.width), modenearest, ) pred_up pred_up.squeeze(0).squeeze(0).numpy().astype(np.uint8)modenearest保证了上采样后的像素值仍然落在整数类别 id 上。如果你想看到彩色结果就准备一个(21, 3)的颜色表直接color_table[pred_up]生成可视化图。这里不用做任何平滑或融合语义分割的可视化本来就是硬语义不像图像超分那样需要过渡。4. 调参才有分割效果训练循环、类别权重与学习率4.1 在一个小训练集上真正跑一次反向传播预训练模型直接推理能得到形状还行的掩码但自己数据集上的类别往往和 COCO 不一致必须微调。下面是一个精简到只剩核心逻辑的训练循环import torch import torch.nn as nn import torch.nn.functional as F # 假设 train_loader 返回 (image, mask) # image 尺寸是 (N, 3, 512, 1024)mask 是 (N, 512, 1024) # mask 的 ignore 区域用 255 填充 model deeplabv3_resnet50(weightsDeepLabV3_ResNet50_Weights.DEFAULT) # 把最后的分类头换成自己的类别数比如 Cityscapes 的 19 类 model.classifier[4] nn.Conv2d(256, num_classes, kernel_size1) # 如果用了 aux 分支也要一起换 model.aux_classifier[4] nn.Conv2d(256, num_classes, kernel_size1) criterion nn.CrossEntropyLoss(ignore_index255, reductionmean) optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) # 常见做法主干学习率乘 0.1新增分类头学习率保持 0.01 backbone_params list(model.backbone.parameters()) cls_params list(model.classifier.parameters()) list(model.aux_classifier.parameters()) optimizer torch.optim.SGD([ {params: backbone_params, lr: 0.001}, {params: cls_params, lr: 0.01}, ], momentum0.9, weight_decay1e-4) for epoch in range(epochs): model.train() # 冻结 BN 的统计量是微调时常见做法防止小 batch 导致均值方差抖动 for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.eval() for images, masks in train_loader: images images.to(device) masks masks.to(device) outputs model(images) loss criterion(outputs[out], masks) # aux 分支损失加 0.4 的权重 loss 0.4 * criterion(outputs[aux], masks) optimizer.zero_grad() loss.backward() optimizer.step()这段代码里最要紧的是model.classifier[4]的替换方式。torchvision 的 DeepLabV3 分类器是[nn.Conv2d(2048, 256), ASPP, BatchNorm, ReLU, nn.Conv2d(256, 21)]索引 4 就是最后的卷积层。如果你修改类别数后忘了同步替换 aux 分类器训练不会报错但损失函数在辅助分支上会收敛到错误维度。冻结 BN 的操作也是微调场景里常被忽略的细节。分割任务的 batch size 通常只有 2~4每个 batch 的均值方差噪声会很大如果 BN 统计量还在更新loss 曲线会像心电图一样。把 BN 切到 eval 模式直接冻结它们可以让微调曲线平滑很多。4.2 必调参数crop_size、随机缩放、权重初始化从实际经验看有四个参数对最终 mIoU 影响最大我把它整理成一张参数速查表参数推荐值影响与原因crop_sizeCityscapes 推荐 512x1024小数据用 384x768决定 GPU 显存和感受野平衡太小会把大物体切断随机缩放0.5~2.0 之间随机采样让模型学到多尺度对尺度差异大的街景很重要lr主干 0.001新分类头 0.01新分类头没有预训练需要更大步长快速收敛ignore_index255缺失标签和边界区域要排除否则模型被噪声标签带偏随机缩放是在transforms里加的一个环节先随机选择scale random.uniform(0.5, 2.0)把原图和标签同时缩放再做随机裁剪。这里有个原则图像和标签必须完全同步缩放。如果只缩放原图、标签保持不变模型会学到错误的空间对应关系loss 震荡但 mIoU 一动不动。4.3 训练过程怎么判断有没有学歪看 loss 的绝对值不如看验证 mIoU训练初期CrossEntropyLoss的值一般是 3 以上因为 21 类的均匀分布信息熵接近 3.04。如果你的 loss 从一开始就低于 0.5先怀疑是不是标签和图像对错了比如所有标签都是同一张掩码。loss 在 100 轮以后降到 1.5 左右并不代表模型已经能用了只是一个相对健康的状态。真正有效的判断办法是每 5 个 epoch 跑一次验证集的 mIoU。Cityscapes 上预训练模型直接评估一个随机初始化的分类头mIoU 会在 2% 到 5% 之间微调 20 个 epoch 后应该能看到涨到 30% 以上这才说明训练是有效的。5. 避坑清单模型收敛了但 mIoU 纹丝不动语义分割项目里最让人崩溃的不是训练不收敛而是损失一直在降、可视化输出全是噪声、验证 mIoU 不动。我把常见的翻车原因按“现象 → 原因 → 解决”格式整理成清单每一条都是我实际排查过的。坑 5.1训练指标很好但输出掩码是纯黑或全零现象loss 降到很低但模型对任何输入都输出全背景类可视化掩码全黑。 原因数据集里背景类占比太高。Cityscapes 里道路、建筑、植被三个类就占了约 60% 像素模型只要把所有像素都预测成这三类loss 也不会太高。这是类别不平衡导致的收敛到局部最优。 解决类别权重策略或者用OhemCrossEntropyLoss难例挖掘。最简单的做法是在CrossEntropyLoss里传入weight把像素占比少的类别权重调大比如行人、摩托车单独设 5 倍权重。另一个办法是验证时一定要看mIoU不要只看accuracy。坑 5.2上采样后掩码边缘出现模糊断层现象倒数第 1 类像素值经常出现在不该出现的位置比如“天空”类别里混入几个值为 19 的杂点可视化看起来像颜色噪声。 原因在索引图上用了双线性上采样。双线性插值会对相邻类别取平均产生像 17.38 这样的非整数索引argmax或astype之后就会落到其他类别上。 解决把F.interpolate的mode改成nearest并在索引图上不做任何平滑操作。如果代码里用了transforms.Resize处理标签也要同步设置interpolationImage.NEAREST。坑 5.3验证 mIoU 一直比训练 loss 低 20 个百分点现象训练集 loss 正常下降验证集 mIoU 只有个位数稳定性也很差。 原因验证时没有对标签做和训练一致的预处理比如训练时做了随机裁剪和缩放验证时直接全图输入模型看不到相对位置信息完整的大物体或者是验证时没有做上采样就计算 IoU模型输出 1/8 分辨率和原图标签对不上。 解决验证时固定crop_size或采用全图输入但把输出上采样回标签分辨率后再算 IoU。我的习惯是验证时不做任何数据增强只做和训练一致的normalize保证标签的像素坐标和输出对齐。坑 5.4训练第一个 epoch 就出现 NaN现象loss 在前 100 个 iteration 突然变成nan模型权重也变成nan。 原因最常见是learning rate过大微调时新增分类头的梯度太大冲爆了其次是学习率策略里用了warmup0直接跳到高学习率在 batch_size 很小的时候更明显。 解决把主干学习率降到 0.001并加一个 5 epoch 的线性 warmup让新分类头慢慢适应。检查一遍数据里是否存在Inf像素值尤其是在数据增强里引入了超出0~1范围的异常值。坑 5.5换 GPU 之后精度变了 2~3 点现象同一份代码在 2080Ti 和 A100 上训练验证 mIoU 稳定差几个点且高版本 PyTorch 上结果更好。 原因CUDAcudnn的卷积算法自动调优在不同显卡上选了不同实现即使torch.manual_seed(0)也无法完全控制。这是语义分割实验里最隐蔽的“黑匣子”之一。 解决训练前设置torch.backends.cudnn.deterministic True并固定torch.manual_seed和torch.cuda.manual_seed_all。这样牺牲一点速度换来可复现性毕设答辩时更好交代。6. 进阶验证用 mIoU 和可视化双确认才算真正跑通模型训练完不要急着写论文先用两个手段确认它是真的能干活。第一个手段是可视化输出对比图。把原图、真实掩码、预测掩码三张图横向拼在一起生成一张对比大图直观检查模型有没有把“汽车”识别成“卡车”有没有在阴影处把“道路”切成“人行道”。可视化阶段一定要用彩色图灰度的索引图人眼根本看不出语义错误import matplotlib import matplotlib.pyplot as plt # 构造一个 21 类的伪彩色表类目可自行调整 num_classes 21 palette np.random.randint(0, 255, size(num_classes, 3), dtypenp.uint8) def save_compare(img, true_mask, pred_mask, save_path): plt.figure(figsize(18, 6)) plt.subplot(1, 3, 1) plt.imshow(img) plt.title(Input) plt.axis(off) plt.subplot(1, 3, 2) plt.imshow(palette[true_mask]) # true_mask 是索引图 plt.title(Ground Truth) plt.axis(off) plt.subplot(1, 3, 3) plt.imshow(palette[pred_mask]) plt.title(Prediction) plt.axis(off) plt.tight_layout() plt.savefig(save_path, bbox_inchestight, dpi150)第二个手段是分类的 mIoU 报告。只盯着平均分不行要把每类的 IoU 打印出来看模型到底是在“杆、交通标志、摩托车”这类小目标上拉胯还是在“天空、道路”这类大区域上出错。一个让我印象很深的教训是某个微调模型平均 mIoU 有 68但“骑手”类只有 19原因是训练集里骑手总是出现在摩托车上模型学会了用摩托车背景去猜类别而不是识别骑手本身的特征。这类问题只有分项指标才能暴露出来。我自己的习惯是任何一次实验调整都保存一份分类别 IoU 表格好用来对比改动前后的变化。哪怕只调了学习率也要跑一遍验证脚本确保没有破坏已有类别的表现。这篇笔记写到这里真正能帮你省时间的不是某个神奇的调参技巧而是把“数据协议、上采样方式、评估方式”这三件事先做对。希望帮到你。本文还有配套的精品资源点击获取