ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch实现Unet多类别语义分割:从数据准备到部署

PyTorch实现Unet多类别语义分割:从数据准备到部署 简介这是一份面向PyTorch学习者的Unet多类别语义分割实战代码包帮助读者在自己的数据集上完成模型构建、训练与评估适合有一定深度学习基础、希望快速上手图像分割任务的开发者。资源共46个文件以19个Python源码和24个编译后的pyc文件为主涵盖网络结构、数据加载、损失函数、学习率调度、评估指标等模块另含配置与说明文本整体仅69KB轻量易用。目前已有15269人学习下载实用性获广泛验证。代码包提供完整的Unet实现输出通道数可随类别数灵活调整并配套交叉熵损失、优化器选择、数据增强等训练流程可直接迁移至医学影像、遥感图像等场景。结合对应博文的步骤讲解能帮助使用者快速理解跳跃连接与编解码结构少走弯路高效落地自己的多类别分割项目。1. 在自己数据集上跑 Unet 语义分割先别急着写模型如果你手头有一批自己的多类别数据集想用 PyTorch 复现 Unet 做语义分割最怕的不是网络结构看不懂而是照着开源仓库跑通后换个数据集就全乱套。网上大多数 Unet 教程都用 VOC 或 Carvana 这类单类别、且背景占比合理的公开数据标签掩码早就处理得整整齐齐轮到你自己的遥感影像、医疗切片或工业缺陷图类别不平衡、标签编码错误、尺寸对不齐三个问题会依次找上门。这篇笔记就围绕「PyTorch 下实现 Unet 对自己多类别数据集的语义分割」这条主线从数据准备、网络实现、损失函数到推理部署按我实际拆过项目的顺序梳理一遍适合刚学会 PyTorch 基础框架、想真正把语义分割算法跑到自己数据上的同学也适合已经跑通过但总在细节上翻车的熟手对照检查。2. 数据准备与标签处理把标注图变成 Unet 能啃的样本2.1 目录组织与标签编码的硬性约定Unet 做语义分割输入是三通道 RGB 图像或单通道灰度图标签是单通道的索引图每个像素的值对应一个类别编号。最容易被忽略的是编号必须从 0 开始连续编排0 是背景1 是第一类物体2 是第二类以此类推。如果你的标注软件导出的 PNG 是灰度值 0、255 这种二值形式或者类别值不连续比如 0、5、10CrossEntropyLoss 会直接报错或者在训练时出现诡异的负数损失。我一般在项目根目录建data/下面分images和masks两个文件夹文件名一一对应data/ images/ img_001.png img_002.png masks/ img_001.png img_002.pngmasks 里每张 PNG 的像素值是0, 1, 2, ...最大类别数 N 减一。如果你的原始标签是 255 代表前景先写一个转换脚本把 255 批量改成 1顺便检查有没有漏网的其他值。from PIL import Image import numpy as np from pathlib import Path src_dir Path(raw_masks) # 原始标签目录 dst_dir Path(data/masks) # 转换后标签目录 dst_dir.mkdir(parentsTrue, exist_okTrue) for p in src_dir.glob(*.png): arr np.array(Image.open(p)) # 常见的二值标签0 背景255 前景 - 映射成 0 和 1 new np.where(arr 0, 1, 0) Image.fromarray(new.astype(np.uint8)).save(dst_dir / p.name) print(转换完成类别数 , len(np.unique(new)))逻辑说明把raw_masks下所有 PNG 读成 numpy 数组用np.where做阈值映射再保存成uint8的单通道图。uint8很重要很多新手存成int64或float32后面 DataLoader 里做 one-hot 或直接喂 CrossEntropyLoss 时类型不匹配会报一堆怪错。参数说明如果你是多类别就不要用0这种二值判断而是写一个类别映射字典比如原来的类别值 1、3、4 对应新的 0、1、2用np.select或者挨个遍历赋值都可以。核心原则是标签数组必须是[0, N-1]的整数区间闭集。2.2 数据增强与 Dataset 类的代码骨架数据增强对医学影像和遥感图像尤其重要因为标注样本往往只有几十张。常见的组合是随机水平翻转、垂直翻转、随机旋转 90 度、随机裁剪注意图像和标签必须使用同一个随机种子否则增强后图像变了但标签没变损失函数直接算出一个天文数字训练没法收敛。import torch from torch.utils.data import Dataset import numpy as np from PIL import Image import random class SegDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size(256, 256), augmentFalse): self.img_dir img_dir self.mask_dir mask_dir self.img_size img_size self.augment augment self.names sorted([p.name for p in img_dir.glob(*.png)]) def __len__(self): return len(self.names) def __getitem__(self, idx): img Image.open(self.img_dir / self.names[idx]).convert(RGB).resize(self.img_size) mask Image.open(self.mask_dir / self.names[idx]).resize(self.img_size) if self.augment: # 图像和标签必须用同一个随机数做翻转 if random.random() 0.5: img img.transpose(Image.FLIP_LEFT_RIGHT) mask mask.transpose(Image.FLIP_LEFT_RIGHT) if random.random() 0.5: img img.transpose(Image.FLIP_TOP_BOTTOM) mask mask.transpose(Image.FLIP_TOP_BOTTOM) img_arr np.array(img, dtypenp.float32) / 255.0 # 归一化到 [0,1] img_arr img_arr.transpose(2, 0, 1) # HWC - CHW mask_arr np.array(mask, dtypenp.int64) # 标签保持整型 return torch.from_numpy(img_arr), torch.from_numpy(mask_arr)逻辑说明读取图像和标签时都用 PIL 的resize统一到固定尺寸避免一个 batch 里尺寸不一致。训练时随机左右翻转和上下翻转推理时关闭增强。归一化必须在转张量之前做因为 PyTorch 的 CrossEntropyLoss 对标签要求是LongTensor不能是 float。参数说明img_size一般取 256 或 512越大越吃显存。augment参数在训练时传True验证和预测时传False。如果你的图像很大比如遥感图 5000×5000正确做法是随机裁剪成 256×256 的小块喂给网络而不是直接 resize否则细小的地物特征全被抹掉了。这个点我在做遥感图像语义分割时踩过小目标物体在 resize 后直接消失。2.3 数据准备的三个前置检查写 DataLoader 之前花五分钟做三个检查能省掉后面几小时的调试检查标签最小值是否是 0、最大值是否是N-1。用np.unique打印标签数组的所有取值看到[0, 1, 1, 0, 3]这种不连续的立刻回去修映射。检查图像和标签是否一一对应。最简单的方法是定义一个校验函数def check_alignment(img_dir, mask_dir): img_names sorted(p.name for p in img_dir.glob(*.png)) mask_names sorted(p.name for p in mask_dir.glob(*.png)) assert img_names mask_names, 图像和标签文件名不一致请检查 print(f共 {len(img_names)} 对样本对齐通过)检查标签里是否有未标注的空白类别。很多开源标注工具导出的标签图未标注区域是 255如果不处理网络会把 255 当成一个真实类别去学。我见过有人训练了几百轮才发现标签里有 255结果输出层多了一个多余的类别分支。3. 模型搭建Unet 结构拆解与 PyTorch 代码实现3.1 为什么多类别分割仍然首选 UnetUnet 是 2015 年提出的编码器-解码器结构编码器逐层下采样提取语义特征解码器逐层上采样恢复空间分辨率中间用跳跃连接把同尺度的低级特征拼接给解码器。这个设计的价值在于下采样让网络拥有足够大的感受野去区分「这是路还是房子」跳跃连接让网络保留住边缘细节不至于上采样后糊成一团。对比 FCN 那种纯反卷积结构Unet 在同等训练条件下收敛更快对小目标的分割精度更高这也是它在遥感影像语义分割和医学图像分割里一直没被淘汰的原因。在多类别场景下Unet 的输出层是一个N通道的特征图每个通道对应一个类别。训练时用 CrossEntropyLoss 在通道维度上计算 softmax 交叉熵推理时在通道维上做argmax得到每个像素的类别编号。整个前向过程本质上就是输入[B, 3, H, W]输出[B, N, H, W]。3.2 从零实现一个双卷积块和主干下面这个实现是我自己整理过的版本去掉了一些花哨的规范化操作只保留 Unet 最核心的元素两次卷积 ReLU batch normalization。特征通道数按经典配置从 64 开始逐层加倍。import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): 两块卷积组成的基础模块每块包含 Conv2d BN ReLU def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, kernel_size3, padding1, biasFalse), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.conv(x)逻辑说明padding1保证卷积前后尺寸不变这是后面跳跃连接能直接拼接的前提。biasFalse是因为 BatchNorm 自带偏移量卷积的 bias 再加上 BN 的偏移是多余的也会拖慢收敛。参数说明out_ch是每个 Down 或 Up 阶段的输出通道数。经典 Unet 的通道序列是 64 → 128 → 256 → 512 → 1024显存不够时可以把起点降到 32。对于轻量数据集64 起步其实已经足够。3.3 编码器、解码器与跳跃连接的完整拼装class UNet(nn.Module): def __init__(self, in_channels3, num_classes3, base_ch64): super().__init__() # 编码器路径 self.enc1 DoubleConv(in_channels, base_ch) # 1/1 分辨率 self.pool1 nn.MaxPool2d(2) self.enc2 DoubleConv(base_ch, base_ch * 2) # 1/2 self.pool2 nn.MaxPool2d(2) self.enc3 DoubleConv(base_ch * 2, base_ch * 4) # 1/4 self.pool3 nn.MaxPool2d(2) self.enc4 DoubleConv(base_ch * 4, base_ch * 8) # 1/8 self.pool4 nn.MaxPool2d(2) self.bottleneck DoubleConv(base_ch * 8, base_ch * 16) # 1/16 # 解码器路径 self.up4 nn.ConvTranspose2d(base_ch * 16, base_ch * 8, kernel_size2, stride2) self.dec4 DoubleConv(base_ch * 16, base_ch * 8) self.up3 nn.ConvTranspose2d(base_ch * 8, base_ch * 4, kernel_size2, stride2) self.dec3 DoubleConv(base_ch * 8, base_ch * 4) self.up2 nn.ConvTranspose2d(base_ch * 4, base_ch * 2, kernel_size2, stride2) self.dec2 DoubleConv(base_ch * 4, base_ch * 2) self.up1 nn.ConvTranspose2d(base_ch * 2, base_ch, kernel_size2, stride2) self.dec1 DoubleConv(base_ch * 2, base_ch) self.out_conv nn.Conv2d(base_ch, num_classes, kernel_size1) def forward(self, x): # 编码器逐层下采样并保存每一层输出用于跳跃连接 e1 self.enc1(x) e2 self.enc2(self.pool1(e1)) e3 self.enc3(self.pool2(e2)) e4 self.enc4(self.pool3(e3)) b self.bottleneck(self.pool4(e4)) # 解码器上采样后与对应编码特征拼接再双卷积 d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out_conv(d1)逻辑说明forward里每个解码阶段的输入都是「上采样结果 对应编码器特征」在通道维上的拼接用dim1拼接是因为 PyTorch 的通道维是第 1 维。输出层用1×1卷积把通道数压到num_classes保持空间分辨率不变。num_classes3就表示三分类背景 两类目标。参数说明in_channels要根据输入图像来定RGB 图是 3灰度图是 1。base_ch是整个网络的通道基数如果你想压缩模型体积改成 32 可以直接减少约 4 倍参数量。用nn.ConvTranspose2d做上采样比较直观很多改进版把转置卷积换成双线性插值 卷积能减少棋盘格伪影但基础版足够跑通语义分割算法流程。3.4 输入尺寸必须能被 2^4 整除这个网络一共做了 4 次 MaxPool2d(2)所以输入尺寸必须是 16 的整数倍否则最后一次下采样后特征图尺寸对不上拼接时直接报错「Sizes of tensors must match」。我一般会在 Dataset 的resize里统一处理成 256×256 或 512×512如果是任意尺寸推理先做 padding 再中心裁剪一圈即可。4. 训练与评估损失函数、训练循环与 mIoU 计算4.1 多类别语义分割的损失函数选型语义分割默认的损失函数是nn.CrossEntropyLoss它对每个像素位置独立计算 softmax 交叉熵再对整张图求平均。遇到类别不平衡比如背景占 95%、目标占 5% 的时候模型会倾向于把所有像素都预测成背景因为这样损失最小。解决办法有两种一是给CrossEntropyLoss传一个weight张量让少数类别的 loss 权重放大二是用 Focal Loss 这类难样本挖掘的损失。import torch.nn as nn # 假设类别 0 占 90%类别 1 占 7%类别 2 占 3% class_weights torch.tensor([0.2, 1.0, 3.0], devicedevice) criterion nn.CrossEntropyLoss(weightclass_weights)逻辑说明weight的每个元素对应一个类别的损失放大系数。背景类别权重小稀有类别权重大模型在梯度更新时会更加关注那些少数类别像素。参数说明权重的绝对值不重要重要的是相对比例。常见做法是按类别像素占比的倒数归一化比如类别出现频率0.9 / 0.07 / 0.03取对数后会更温和。注意weight必须放在与输入相同的device上否则训练到一半会报设备不一致的错误。4.2 训练循环正向传播、反向传播和验证训练循环的骨架几乎固定需要注意的是每一轮训练前要model.train()验证前要model.eval()。eval模式会关闭 Dropout 和 BatchNorm 的随机行为否则验证指标忽高忽低你会误以为是模型没收敛。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 for imgs, masks in loader: imgs imgs.to(device) masks masks.to(device) optimizer.zero_grad() outputs model(imgs) # [B, N, H, W] loss criterion(outputs, masks) # [B, N, H, W] vs [B, H, W] loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) return total_loss / len(loader.dataset) def validate(model, loader, criterion, device): model.eval() total_loss 0.0 with torch.no_grad(): for imgs, masks in loader: imgs imgs.to(device) masks masks.to(device) outputs model(imgs) loss criterion(outputs, masks) total_loss loss.item() * imgs.size(0) return total_loss / len(loader.dataset)逻辑说明torch.no_grad()在验证时生效不保存计算图省显存也加快速度。损失累加时乘以imgs.size(0)是为了按样本数加权避免最后一个 batch 样本数不足导致平均值偏斜。参数说明优化器我习惯用 Adam初始学习率1e-4。语义分割对学习率很敏感设置太大会让损失在训练早期剧烈震荡太小则几百轮都降不下来。实践里常见做法是用一个ReduceLROnPlateau调度器验证损失连续 5 轮不降就降低一半学习率这是我实测最稳的方案。4.3 评估指标IoU 和 mIoU 的实现细节分类任务看准确率但语义分割里背景像素太多准确率虚高必须看 IoU。每个类别的 IoU 定义是该类别的预测掩码与真实掩码的交集面积除以并集面积。多类别就取所有类别 IoU 的平均值叫 mIoU。def compute_miou(pred, target, num_classes): pred: [B, H, W] 预测类别索引 target: [B, H, W] 真实类别索引 ious [] for cls in range(num_classes): pred_mask (pred cls) target_mask (target cls) inter (pred_mask target_mask).sum().item() union (pred_mask | target_mask).sum().item() if union 0: ious.append(float(nan)) # 该类别在当前 batch 未出现 else: ious.append(inter / union) return np.nanmean(ious)逻辑说明逐类别计算交集和并集后累加最后用np.nanmean跳过缺失类别防止某个类别在整批数据里没出现导致平均值为 0。参数说明pred来自model(imgs).argmax(dim1)target是数据集的masks。在验证每个 epoch 时累计所有样本的 pred 和 target最后一个 epoch 结束后一次性计算 mIoU比每个 batch 单独算再平均更准确。5. 避坑指南多类别 Unet 训练中容易翻车的五个高频坑5.1 训练损失正常下降但预测图全是一片背景现象模型训练的 loss 从 2.5 降到 0.3看着没问题但把验证集的预测结果画出来几乎所有像素都被预测成背景类别少数类别完全消失。原因类别极度不平衡背景占了 95% 以上交叉熵损失被背景类主导。模型发现只要把所有像素都判成背景准确率就有 95%而我们的 mIoU 会因为少数类别完全没预测出而掉到 0.2 以下。解决给 CrossEntropyLoss 加weight让少数类别获得 510 倍损失权重或者改用 Focal Loss。还有一个更直接的挽救手段对训练集中的图像做随机裁剪尽量让裁剪块里包含前景目标而不是整张原图直接缩放。我做过一次遥感影像分割实验从整图缩放改成滑窗裁剪后小类别 IoU 直接涨了 12 个点。5.2 尺寸不匹配导致跳跃连接直接报错现象torch.cat时报错提示两个张量的H和W不一致常常发生在编码器输出「e4」和解码器上采样后的特征拼接时。原因输入图像尺寸不是 16 的整数倍。Unet 的 4 次下采样和 4 次上采样中只要原始尺寸不能整除 2^4最后一次上采样后的尺寸就和编码器端的特征图差 1 到 2 个像素。解决在数据预处理里把图像和标签统一 resize 到 256×256 或 512×512这是最省事的方案。如果你必须保持原始比例就先 padding 到 16 的整数倍推理完成后再把预测结果裁剪回原始尺寸。注意 resize 标签时用Image.NEAREST而不是默认的双线性插值。双线性会把 0 和 1 插值成 0.4、0.7 这种小数标签就废了。5.3 conda 命令无法识别环境搭建卡在第一步现象打开 PowerShell 输入conda activate pytorch提示「无法将“conda”项识别为 cmdlet、函数、脚本文件或可运行程序的名称」接着执行python -V却仍然能进入系统 Python。原因安装 Anaconda 时没有勾选「Add to PATH」或者当前终端会话里没有加载 conda 的初始化脚本。解决在 PowerShell 里执行conda init powershell然后重新打开终端。如果还不行检查用户环境变量里有没有C:\Users\你的用户名\anaconda3和C:\Users\你的用户名\anaconda3\Scripts。千万别手动改系统的 PATH把 conda 装到C:\Program Files这类带空格的目录后后续包管理器各种诡异路径错误会一直纠缠你。PyTorch 环境换国内镜像装会把下载速度提升几个量级这是很多刚装 PyTorch 的初学者最容易忽略的一步。5.4 显卡有但是torch.cuda.is_available()返回 False现象电脑装了 NVIDIA 显卡驱动也更新了执行torch.cuda.is_available()却一直返回 False训练只能走 CPU速度慢得没法看。原因最常见的是 PyTorch 版本是 CPU 版比如直接从默认的 PyPI 源pip install torch那个包默认不带 CUDA 支持。另外 CUDA 版本和 PyTorch 版本不匹配也会导致类似问题比如你的显卡驱动支持的 CUDA 是 12.1但装的是编译到 CUDA 11.8 的 PyTorch。解决先执行nvidia-smi看右上角的 CUDA 支持版本再去 PyTorch 官网选对应版本的安装命令比如 CUDA 12.1 对应pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121。英特尔核显用户直接放弃 GPU 版AMD 显卡用户可以考虑 ROCm 或继续 CPU 训练别在这上面浪费时间。装完后跑一行验证代码再进入训练流程。5.5 验证集 mIoU 摇摆不定总是比训练集差一大截现象训练 loss 稳定下降但每个 epoch 的验证 mIoU 起伏很大有时候 0.7有时候 0.4让人怀疑是随机性太大还是过拟合。原因一方面验证集样本太少单张图就能让 IoU 掉很多另一方面 BatchNorm 在eval模式下使用训练集累计的均值和方差如果训练集与验证集分布差异大特征分布会发生偏移导致验证时效果不稳定。解决确保验证集至少占总样本的 15%20%且每类的分布与训练集一致。如果你发现验证 mIoU 始终低于训练 mIoU 十几个点先把momentum调大一点试试或者直接用 GroupNorm 替换掉所有 BatchNorm它对小 batch 更友好。我从那以后每次训练前都会打印训练集和验证集里每个类别的像素占比确认两者的类别分布差异在 5% 以内再开始训练。6. 把模型用起来推理、调色板可视化与 ONNX 一致性验证6.1 单张图像推理的标准流程训练结束保存model.state_dict()推理时重新加载。别忘了在推理前调用model.eval()并把输入图像做和训练时一样的归一化。import torch from PIL import Image import numpy as np device torch.device(cuda if torch.cuda.is_available() else cpu) model UNet(in_channels3, num_classes3).to(device) model.load_state_dict(torch.load(best_model.pth, map_locationdevice)) model.eval() def predict_single(image_path): img Image.open(image_path).convert(RGB).resize((256, 256)) arr np.array(img, dtypenp.float32) / 255.0 tensor torch.from_numpy(arr.transpose(2, 0, 1)).unsqueeze(0).to(device) with torch.no_grad(): logits model(tensor) # [1, N, 256, 256] pred logits.argmax(dim1).squeeze(0).cpu().numpy() # [256, 256] return pred逻辑说明argmax(dim1)在通道维上取最大概率对应的类别编号得到单通道索引图。加载权重时用map_locationcpu可以避免在只有 CPU 的机器上因为设备不匹配而加载失败。参数说明如果推理原图不是 256×256预测出来的掩码尺寸也是 256×256需要resize回原图大小后再做可视化或保存。注意这里不需要做任何阈值处理语义分割是多分类问题直接取最大 logits 即可。6.2 用调色板把类别索引变彩色图索引图直接保存出来是灰蒙蒙的因为 0、1、2 这几个数值在灰度图上几乎看不出差别。可视化时需要给每个类别指定一种颜色我一般写一个调色板函数。def mask_to_color(mask, palette): mask: [H, W] 类别索引 palette: 字典{类别编号: (R, G, B)} color_mask np.zeros((*mask.shape, 3), dtypenp.uint8) for cls, color in palette.items(): color_mask[mask cls] color return Image.fromarray(color_mask) # 三类背景黑、建筑红、植被绿 palette {0: (0, 0, 0), 1: (255, 0, 0), 2: (0, 255, 0)} mask_to_color(pred, palette).save(pred_color.png)逻辑说明遍历每个类别用布尔索引给对应像素位置填色。这张彩色图可以叠加到原始图像上半透明显示方便肉眼核对边界。参数说明palette的类别编号必须和训练时一致比如训练时标签里 2 是植被可视化时 2 就必须填绿色这对不齐会误导你对模型效果的判断。6.3 转 ONNX 检查导出一致性多类别语义分割模型常用在服务端推理把 PyTorch 模型转成 ONNX 后可以直接用 OpenCV DNN 模块加载摆脱对 PyTorch 环境的依赖。这个细节很多教程没提但实际部署时非常重要。转完后必须做一次一致性验证确认导出前后输出完全一致。model.eval() dummy torch.randn(1, 3, 256, 256).to(device) torch.onnx.export( model, dummy, unet.onnx, input_names[input], output_names[output], opset_version11, dynamic_axes{input: {0: batch_size}, output: {0: batch_size}}, ) # 验证 ONNX 输出和 PyTorch 输出是否一致 import onnxruntime as ort ort_session ort.InferenceSession(unet.onnx, providers[CPUExecutionProvider]) ort_out ort_session.run(None, {input: dummy.cpu().numpy()})[0] pytorch_out model(dummy).detach().cpu().numpy() print(最大误差 , np.abs(ort_out - pytorch_out).max())逻辑说明dynamic_axes把 batch 维度设为动态这样导出的模型在推理时可以传入任意 batch size。ONNX Runtime 的输出与 PyTorch 输出做逐元素减法最大误差通常在1e-6量级如果误差大于1e-3优先检查 BatchNorm 是否处于eval状态以及输入是否做了一致归一化。参数说明opset_version11是兼容性较好的选择新版 PyTorch 支持更新的 opset但部署端如果是老版本 TensorRT版本太高反而会报不支持的操作符。转 ONNX 前必须保证model.eval()否则 BatchNorm 的行为会和推理不一致。从最初在自己标注的遥感数据上反复翻车到今天能一套脚本从数据准备直接跑到 ONNX 导出我最大的教训是语义分割工程里 80% 的坑都出在数据、尺寸和模式切换上而不是网络结构本身。从那以后我每次训练前都强制走一遍「标签编码检查 → 尺寸对齐检查 → 类别占比统计 → 验证集分布比对」这套流程同时也建议你保留这份习惯至少能在后续做 Unet 模型改进时少走两次弯路。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进