简介:这份资源围绕全卷积网络(FCN)与Penn-Fudan行人分割数据集展开,面向已掌握卷积神经网络基础、希望动手实践语义分割的深度学习学习者,可用于理解端到端像素级预测、上采样与跳跃连接等关键机制。包内共533个文件,以340个png图像与分割掩模、172个txt标注及训练日志为主,另有6个Python训练脚本、4个npy评估指标文件(meanIU、meanPixel)与4个xml标注,压缩包约184.68MB;并附带多组按batch、epoch、优化器与学习率命名的训练记录,便于对照不同超参数配置。目前已有507人学习。读者可直接获得可运行的训练与预测代码、带精细轮廓标注的街景数据、逐像素精度评估结果及实验日志,省去数据整理与环境搭建的重复工作,适合课程作业、分割入门实验与行人检测分割模型的复现参考。
1. 从一张“全黑”的预测图说起:全卷积网络在 Penn-Fudan 上到底解决什么问题
第一次用全卷积网络(FCN)跑 Penn-Fudan 这份行人分割数据集时,我盯着验证集 0.94 的像素准确率高兴了不到一分钟,把预测 mask 存成 PNG 一看,整张图全黑——模型学会的是“把每个像素都判成背景”。这个项目做的事其实很朴素:输入一张 RGB 街景图,输出一张同尺寸的前景/背景概率图,标出画面里每一个行人的像素轮廓。它不输出检测框,也不区分实例,只回答“这个像素是不是人”。适合刚接触语义分割、想把“骨干网络 + 跳接 + 上采样”这条链路彻底搞明白的开发者,也适合拿它当模板,迁移到自己的二分类像素级任务上。
2. 数据管线拆解:Penn-Fudan 的 mask 怎么读、怎么切、怎么喂给全卷积网络
数据这一层没搞对,后面调参全是玄学。Penn-Fudan 这份数据的组织方式很“原始”:原图和标注图是同名文件,标注图里不同行人用不同的整数值区分。这套约定跟现在很多数据集直接给 0/1 二值 mask 的习惯不一样,所以第一步不是写模型,而是把目录摸清楚、把标签压平。我一般在动手前先列出工程目录,确认每个文件负责什么,再决定 Dataset 该怎么写。
| 路径 | 作用 |
|---|---|
data/PennFudanPed/PNGImages/ | 原始 RGB 图,文件名形如FudanPed00001.png |
data/PennFudanPed/PedMasks/ | 同名掩码,后缀加_mask,像素值 0 为背景、1/2/3… 为不同行人 |
datasets/penn_fudan.py | Dataset 与联合增广,负责读图、压标签、归一化 |
models/fcn.py | ResNet34 骨干 + 1×1 分类头 + 跳接上采样 |
losses.py | BCE + Dice 组合损失 |
train.py/predict.py | 训练入口、推理并叠色落盘 |
configs/default.yaml | 输入尺寸、学习率、epoch、阈值 |
2.1 目录约定与依赖:先确认手里这份数据是完整的
装依赖这一步没必要追新,torch>=2.0、torchvision>=0.15就够,pillow和numpy是读图刚需,onnx/onnxruntime等到要导出时再装。装完先跑一段校验脚本,确认原图和掩码是一一对应的,因为这份数据在不同来源的打包里偶尔会缺几对文件,训练到一半才发现会很难受。
pip install torch torchvision pillow numpy pyyaml tqdm# check_data.py:跑一遍就知道数据完不完整 import os root = "data/PennFudanPed" imgs = sorted(n for n in os.listdir(f"{root}/PNGImages") if n.endswith(".png")) msks = set(os.listdir(f"{root}/PedMasks")) missing = [n for n in imgs if n.replace(".png", "_mask.png") not in msks] print(f"原图 {len(imgs)} 张,掩码 {len(msks)} 张,缺失 {len(missing)} 对") assert not missing, missing[:5]这段脚本干的事就两件:比对文件名、打印数量。常见的一份完整数据是 170 张原图、标注了 300 多个行人,每张图里的人数从 1 到 6 不等,尺寸也不统一(大多在 250×500 到 600×1000 之间)。这个“数量对不上就直接 assert 失败”的习惯救过我不少次,比训练跑完发现验证集只有 3 张图靠谱得多。
2.2 Dataset 实现:把多值 mask 压成二分类前景
很多人第一次写这份数据的 Dataset,会直接把掩码原样返回,结果CrossEntropyLoss报 “Target 5 is out of bounds”。原因是掩码里第 5 个行人的像素值是 5,而你的分类头只有 2 个类别。正确的做法是先做一次> 0的布尔运算,把“任意行人”统一成前景 1,这才是语义分割要的标签。
import os import numpy as np import torch from PIL import Image from torch.utils.data import Dataset class PennFudanDataset(Dataset): """一张原图 + 一张同名 _mask.png,返回 (img_tensor[3,H,W], mask[1,H,W] float)""" def __init__(self, root, split="train", transform=None, seed=42, train_ratio=0.8): img_dir = os.path.join(root, "PennFudanPed", "PNGImages") mask_dir = os.path.join(root, "PennFudanPed", "PedMasks") names = sorted(n for n in os.listdir(img_dir) if n.endswith(".png")) # 固定种子划分,保证每次运行 train/val 完全一致,指标才可比 idx = np.arange(len(names)) np.random.RandomState(seed).shuffle(idx) n_train = int(len(names) * train_ratio) keep = idx[:n_train] if split == "train" else idx[n_train:] self.items = [ (os.path.join(img_dir, names[i]), os.path.join(mask_dir, names[i].replace(".png", "_mask.png"))) for i in keep ] self.transform = transform def __len__(self): return len(self.items) def __getitem__(self, i): img_path, mask_path = self.items[i] img = Image.open(img_path).convert("RGB") mask = np.array(Image.open(mask_path)) # uint8, 值域 0..N # 关键一步:多实例掩码 -> 二值前景掩码,形状保持 [H, W] mask = (mask > 0).astype(np.float32) if self.transform is not None: img, mask = self.transform(img, mask) # mask 补上通道维,和网络输出 [N,1,H,W] 对齐 return img, torch.as_tensor(mask).unsqueeze(0)参数上有几个点值得说清楚。seed固定住是为了让实验可复现,换train_ratio只影响划分比例,不影响其他逻辑;返回的 mask 是float32且带通道维,这样 BCE 损失里不用再手动unsqueeze,形状链条从数据到损失是一致的。如果你更习惯双通道输出(背景/行人)配CrossEntropyLoss,只要把(mask > 0).astype(np.float32)换成astype(np.int64)并去掉unsqueeze(0),同时把模型输出通道改成 2,其他地方不用动。
2.3 同步增广与归一化:图像和标签必须走同一条几何变换
数据增强这里翻车最不容易察觉。你随手用了torchvision.transforms.RandomHorizontalFlip,它只翻转图像,标签纹丝不动,模型看到的是“人往左走、掩码在右边”,训练几十轮 IoU 卡在 0.3 上不去,你还以为是学习率的问题。几何变换必须自己写一个同时作用于图和标签的类。
import random import numpy as np import torchvision.transforms.functional as TF from PIL import Image class JointTransform: """图像与标签共享同一套几何变换,之后再单独做归一化""" def __init__(self, size=(256, 512), train=True): self.size = size # (H, W),必须是 32 的整数倍 self.train = train def __call__(self, img, mask): if self.train and random.random() < 0.5: img = TF.hflip(img) mask = np.fliplr(mask).copy() # numpy 用 np.fliplr,不能用 TF img = TF.resize(img, self.size, interpolation=Image.BILINEAR) # 标签必须最近邻,双线性会插出 0.37 这种非法值 mask = np.array( Image.fromarray(mask).resize((self.size[1], self.size[0]), Image.NEAREST) ).astype(np.float32) img = TF.to_tensor(img) # [3,H,W],值域 0~1 img = TF.normalize(img, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # ImageNet 统计量 return img, mask三个细节:TF.resize的size是(H, W),而PIL.Image.resize收的是(W, H),所以第 18 行做了个[::-1]式的调换,写反了图会被拉成竖条。归一化用的是 ImageNet 的均值方差,因为骨干是加载 ImageNet 预训练的,输入分布对齐了收敛才快。尺寸选 256×512 是我在 8G 显存下的折中,行人本身细长,横向分辨率给足比纵向更重要;显存富余就上 384×768,小目标边缘会明显更干净。
3. 模型拆解:ResNet34 + 跳接上采样,一次前向里的三次分辨率变化
FCN 的核心思想一句话能说完:把分类网络末尾的全连接层换成 1×1 卷积,网络就变成了“每个位置都输出一个类别分数”的稠密预测器。但这句话落到代码里有三个必须交代清楚的地方——骨干在哪一层切、跳接怎么加、最终怎么把 1/32 的特征图还原回原图尺寸。这三个地方决定了你的模型是能跑,还是能跑好。
3.1 骨干切分:从 conv1 到 layer4 各输出多大
拿 ResNet34 当骨干,标准结构是conv1(7×7, stride2) → maxpool(3×3, stride2)先把分辨率降到 1/4,然后四个 stage 依次是 1/4、1/8、1/16、1/32,通道数 64/128/256/512。FCN 需要的是中间两层的高分辨率特征,所以我在代码里显式把五个部分拆开持有,而不是用nn.Sequential(*list(net.children())[:-2])这种一把梭的写法——后面要加跳接、要换骨干、要冻结前几层,拆开才好动手。
| 阶段 | 组成 | 输出步长 | 通道数 |
|---|---|---|---|
| stem | conv1 + bn + relu + maxpool | 1/4 | 64 |
| layer1 | 3 × BasicBlock | 1/4 | 64 |
| layer2 | 4 × BasicBlock | 1/8 | 128 |
| layer3 | 6 × BasicBlock | 1/16 | 256 |
| layer4 | 3 × BasicBlock | 1/32 | 512 |
有个变体值得知道:如果行人在画面里占比很小,1/32 那层的感受野已经太大了,边缘细节全丢。这时候把layer4的第一个块改成stride=1, dilation=2(也就是常见的 dilated backbone),输出就停在 1/16,整个网络少一次下采样,小目标 IoU 通常能涨几个点,代价是显存和计算量上去一截。我一般先用标准版跑通,确认流程没问题再换空洞版本对比。
3.2 FCNHead 与跳接融合:为什么先 1×1 降维再相加
上采样最省事的写法是ConvTranspose2d(512, 1, 64, stride=32)一把还原,但参数量大、还容易出现棋盘格伪影。我的做法是“1×1 卷积出 logits,然后双线性插值 + 逐元素相加”的 FCN8s 结构:1×1 卷积等价于在每个位置上做一个线性分类器,参数量只有 512×1;融合时把深层(语义强、位置糙)和浅层(位置准、语义弱)的 logits 直接相加,靠后面的双线性插值一次性放大 8 倍。
import torch import torch.nn as nn import torch.nn.functional as F from torchvision.models import resnet34, ResNet34_Weights class FCN8s(nn.Module): def __init__(self, num_classes=1, pretrained=True): super().__init__() weights = ResNet34_Weights.IMAGENET1K_V1 if pretrained else None net = resnet34(weights=weights) # 手动拆骨干,方便取中间层做跳接 self.stem = nn.Sequential(net.conv1, net.bn1, net.relu, net.maxpool) # 1/4 self.layer1, self.layer2 = net.layer1, net.layer2 # 1/4, 1/8 self.layer3, self.layer4 = net.layer3, net.layer4 # 1/16, 1/32 # 每层一个 1x1 分类头,把通道压到类别数(二分类就是 1) self.score4 = nn.Conv2d(512, num_classes, 1) self.score3 = nn.Conv2d(256, num_classes, 1) self.score2 = nn.Conv2d(128, num_classes, 1) self._init_weights() def _init_weights(self): # 分类头用小的正态初始化,避免一开始输出过大把 BCE 顶爆 for m in (self.score4, self.score3, self.score2): nn.init.normal_(m.weight, std=0.01) nn.init.constant_(m.bias, 0) def forward(self, x): x = self.stem(x) c2 = self.layer2(self.layer1(x)) # 1/8, 128 ch c3 = self.layer3(c2) # 1/16, 256 ch c4 = self.layer4(c3) # 1/32, 512 ch s = self.score4(c4) # 1/32 s = F.interpolate(s, size=c3.shape[-2:], mode="bilinear", align_corners=False) + self.score3(c3) # 融合到 1/16 s = F.interpolate(s, size=c2.shape[-2:], mode="bilinear", align_corners=False) + self.score2(c2) # 融合到 1/8 return F.interpolate(s, scale_factor=8, mode="bilinear", align_corners=False) # 回到原尺寸num_classes=1意味着输出是单通道 logit,配合BCEWithLogitsLoss使用,改任务时把它设成 2 以上就能做多类。pretrained=True会去加载 ImageNet 权重,第一次运行需要联网,之后走本地缓存。_init_weights里那个std=0.01不是玄学,是血泪经验:默认初始化下分类头初始输出偏大,sigmoid直接饱和到 0 或 1,前几个 epoch 的梯度几乎为零,看起来就像“模型不学”。最后一步scale_factor=8而不是写死size=(H, W),是为了让模型支持任意输入尺寸,推理时不用改代码。
3.3 输出对齐与参数量核对:写两行代码验证形状
模型写完别急着训练,先拿一个假输入跑一遍,把每一层的输出形状打出来。这步花两分钟,能省掉后面半小时的size mismatch排查。用 hook 挂在子模块上是最省事的做法。
import torch from models.fcn import FCN8s model = FCN8s(num_classes=1, pretrained=False).eval() feats = {} for name, mod in model.named_children(): mod.register_forward_hook(lambda m, i, o, n=name: feats.__setitem__(n, tuple(o.shape))) with torch.no_grad(): out = model(torch.randn(2, 3, 256, 512)) for k, v in feats.items(): print(f"{k:10s} -> {v}") print("输出:", tuple(out.shape)) # 期望 (2, 1, 256, 512) print("参数量: %.2fM" % (sum(p.numel() for p in model.parameters()) / 1e6))期望的输出是(2, 1, 256, 512),和输入完全对齐;参数量大概在 21M 出头,主要来自 ResNet34 骨干,三个 1×1 头加起来不到 0.5M。如果输出尺寸比输入小一圈,八成是输入的长宽不是 32 的整数倍,最后一次scale_factor=8少放大了一点;如果 hook 打印出layer4 -> (2, 512, 8, 16)这样的形状,说明整体链路是对的。
4. 训练与评估:损失函数、IoU 指标和一条能跑完的训练循环
数据通了、模型形状对了,剩下的就是把训练循环写扎实。这个任务有个天然的不平衡:一张图里行人像素通常只占 5%~15%,剩下全是背景。损失函数和评估指标必须同时考虑这件事,否则你会得到开头那种“准确率 0.94、IoU 0.1”的假象。
4.1 BCE + Dice:行人只占画面几个百分点时怎么算损失
纯 BCE 在极端不平衡下会被背景主导,梯度大部分来自“预测对背景”这种简单样本。我的组合是 BCE 加一个pos_weight提权前景,再叠一个 Dice 项直接优化重叠度——Dice 对区域大小不敏感,前景小的时候依然有稳定梯度。
import torch import torch.nn as nn import torch.nn.functional as F class BCEDiceLoss(nn.Module): def __init__(self, bce_weight=0.5, pos_weight=2.0): super().__init__() self.bce_weight = bce_weight # pos_weight 当作 buffer,随模型一起搬到 GPU,不用每次手动 to(device) self.register_buffer("pos_weight", torch.tensor([pos_weight])) def forward(self, logits, target): # logits: [N,1,H,W] target: [N,1,H,W] 取值 {0,1} bce = F.binary_cross_entropy_with_logits( logits, target, pos_weight=self.pos_weight) prob = torch.sigmoid(logits) inter = (prob * target).sum() dice = 1 - (2 * inter + 1.0) / (prob.sum() + target.sum() + 1.0) return self.bce_weight * bce + (1 - self.bce_weight) * dicepos_weight=2.0表示前景样本的损失权重是背景的两倍,行人占比更低的场景可以调到 3~5;bce_weight控制两项的比重,0.5 对半分,如果发现边缘糊得厉害就把它降到 0.3,让 Dice 主导。Dice 分母加的1.0是平滑项,防止某张图里一个行人都没有时出现除零。这个损失在整批上算 Dice 而不是逐图算再平均,好处是 batch 内样本少的时候方差小,坏处是一张特别大的图会主导梯度,两种写法我都用过,差别不大,按习惯选。
4.2 训练循环:AMP、梯度累积与学习率调度
训练循环里有几个默认值踩过坑之后就固定下来了:drop_last=True(最后一个 batch 只有一张图时 BN 会报错)、AdamW配weight_decay=1e-4、余弦退火、混合精度。混合精度的 API 在新版本torch里有变化,用torch.amp.autocast(device_type="cuda")更稳。
import torch from torch.utils.data import DataLoader from datasets.penn_fudan import PennFudanDataset, JointTransform from models.fcn import FCN8s from losses import BCEDiceLoss dev = "cuda" if torch.cuda.is_available() else "cpu" size = (256, 512) train_ds = PennFudanDataset("data", "train", JointTransform(size, train=True)) val_ds = PennFudanDataset("data", "val", JointTransform(size, train=False)) train_loader = DataLoader(train_ds, batch_size=4, shuffle=True, num_workers=4, drop_last=True, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=1, shuffle=False, num_workers=2) model = FCN8s(num_classes=1).to(dev) criterion = BCEDiceLoss(bce_weight=0.5, pos_weight=2.0).to(dev) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) scaler = torch.amp.GradScaler("cuda") for epoch in range(30): model.train() running = 0.0 for img, mask in train_loader: img, mask = img.to(dev, non_blocking=True), mask.to(dev, non_blocking=True) with torch.amp.autocast("cuda", dtype=torch.float16): loss = criterion(model(img), mask) optimizer.zero_grad(set_to_none=True) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(optimizer) scaler.update() running += loss.item() * img.size(0) scheduler.step() print(f"epoch {epoch:02d} loss {running / len(train_ds):.4f} " f"lr {scheduler.get_last_lr()[0]:.2e}")clip_grad_norm_(max_norm=5.0)在混合精度下几乎是必需品,FP16 偶尔会出大梯度把权重打飞,加上之后 loss 曲线会明显平滑。pin_memory=True配合num_workers=4能让数据加载不成为瓶颈——170 张图的数据集跑一轮只要十几秒,如果发现 GPU 利用率上不去,八成是 num_workers 太小或者忘了non_blocking。batch size 给到 4 是 8G 显存的极限,显存不够就减到 2 并把 epoch 翻倍,效果差不多。
4.3 验证与可视化:把 mask 叠回原图看效果
只看 loss 是会骗人的,必须有一个显式的 IoU 计算和一张叠色图。IoU 我按 batch 内逐图算再平均,而不是把整个验证集的像素堆起来算,因为后者会被大图主导;对行人数一样多的场景两者差别不大,但逐图平均更贴近“每张图分得好不好”。
@torch.no_grad() def evaluate(model, loader, dev, thr=0.5): model.eval() ious, dices = [], [] for img, mask in loader: img, mask = img.to(dev), mask.to(dev) prob = torch.sigmoid(model(img)[:, 0]) # [N,H,W] pred, tgt = prob > thr, mask[:, 0] > 0.5 inter = (pred & tgt).sum((1, 2)).float() union = (pred | tgt).sum((1, 2)).float() ious.append((inter + 1e-6) / (union + 1e-6)) dices.append((2 * inter + 1e-6) / (pred.sum((1, 2)) + tgt.sum((1, 2)) + 1e-6)) return torch.cat(ious).mean().item(), torch.cat(dices).mean().item()叠色图这块,关键是概率图要一次性 resize 回原图尺寸再二值化,而不是先二值化再放大(后者边缘会是锯齿块)。
import numpy as np import torchvision.transforms.functional as TF from PIL import Image def save_overlay(img_path, prob, out_path, thr=0.5): """prob: [h,w] 的 tensor,网络输出的 sigmoid 结果""" img = Image.open(img_path).convert("RGB") # 注意 TF.resize 收 (H, W),PIL 的 size 是 (W, H),这里要反过来 prob = TF.resize(prob[None], img.size[::-1], interpolation=Image.BILINEAR)[0] m = (prob > thr).numpy().astype(bool) color = np.zeros((*m.shape, 3), dtype=np.uint8) color[m] = (255, 40, 40) overlay = (np.array(img) * 0.55 + color * 0.45).astype(np.uint8) Image.fromarray(overlay).save(out_path)训练 30 个 epoch 左右,验证集 IoU 大概能到 0.75~0.85,Dice 0.85~0.9。如果你跑出来的数字明显低于这个区间,先别怀疑模型结构,回头检查 2.3 的同步增广和 2.2 的标签压平——这两个地方出错的比例远高于超参数没调好。
5. 常见问题排查:五个真实翻车现场与对应的修法
5.1 数据与标签类
现象:训练第一轮就抛Target 3 is out of bounds,或者 loss 一直是nan。原因:直接返回了原始多值掩码没有做> 0压平,或者用双线性插值 resize 掩码,插出了 0.37、1.62 这类非法值。解决:在__getitem__里强制(mask > 0),并且掩码的所有几何变换一律用Image.NEAREST。压平之后顺手打印一次np.unique(mask),只该出现两个值。
现象:loss 正常下降,但验证 IoU 死活卡在 0.3 左右,叠色图上掩码和人完全错位。原因:几何增广只作用在图像上,标签没跟着翻转/裁剪。模型学到的是错位的对应关系,训练集 loss 也降不干净。解决:用 2.3 那种联合变换类,图像和标签在同一分支里一起处理。判断方法很简单:把训练集某一对 (img, mask) 可视化十次,如果人像和掩码有任意一次不重合,就是这里的问题。
5.2 形状与显存类
现象:前向时报The size of tensor a (16) must match the size of tensor b (15),出现在跳接相加那一行。原因:输入长宽不是 32 的整数倍,1/8、1/16、1/32 三层各自向下取整后就对不齐了,F.interpolate(size=...)能救,但如果你写的是ConvTranspose2d上采样,尺寸由公式算出来,必然错位。解决:训练和推理统一把输入 pad 或 resize 到 32 的倍数,配置里写死(256, 512)这种值;推理侧用size=c3.shape[-2:]逐层对齐,最后一步再按原图尺寸插值。
现象:抛Expected more than 1 value per channel when training,报错位置在 BatchNorm。原因:最后一个 batch 只剩下 1 张图,BN 在 N=1 时无法计算批统计量。解决:DataLoader(..., drop_last=True)。验证集不受影响,因为model.eval()走的是 running stats。如果你的数据集小到不能丢样本,就换GroupNorm或者把 BN 冻住。
5.3 指标与权重类
现象:训练日志里 pixel accuracy 0.94,但存出来的图全黑,IoU 只有 0.1 甚至 0.02。原因:背景占比太高,模型把全部像素判成背景就能拿到 0.9 以上的准确率。这个指标在这个任务上几乎没有信息量。解决:训练日志只打 IoU 和 Dice,把 pixel accuracy 从报表里删掉。另外固定 0.5 阈值未必最优,验证集上扫一遍 0.3~0.7 的阈值看 Dice 曲线,通常 0.4 附近会更好一点,尤其是用了pos_weight之后模型偏激进,阈值该往上调。
现象:加载预训练权重时一堆Unexpected key(s) in state_dict: "aux_classifier.0.weight", "classifier.1.weight"...,或者自己训的权重加载后指标归零。原因:拿的是别的分割实现(比如某个双通道 + 辅助分类器的版本)的 checkpoint,键名和本工程对不上;或者本工程改过num_classes,最后的分类头形状已经变了。解决:用model.load_state_dict(ckpt, strict=False)先加载骨干部分,再检查哪些键被忽略;分类头的键名一定是score2/score3/score4.*,对不上就说明加载的根本不是这套结构。自己存权重时固定存{"model": model.state_dict(), "epoch": e, "iou": best},别裸存 state_dict,后面加字段很难受。
6. 二次开发:换数据、换骨干与导出 ONNX 的三条改造路线
这套代码的复用价值在于结构清晰,改动点集中。换数据集最省事:新建一个 Dataset 类,只要保证__getitem__返回[3,H,W]的归一化张量和[1,H,W]的 0/1 掩码,其余代码一行不用改。如果你的新任务是多类(比如道路/车辆/行人三分类),把num_classes改成 4(含背景),损失换成带weight的CrossEntropyLoss,标签那边去掉> 0压平改成mask原样返回,evaluate里按类别循环算 IoU 再平均——改动量大概三十行,别急着重写工程。
换骨干要先看清楚通道数。把resnet34换成resnet50,score4/score3/score2的输入通道要分别改成 2048/1024/512,其余不动;换成轻量的mobilenet_v3_large,得用features里的索引切片取 1/8、1/16、1/32 三层,通道是 24/40/1120,顺手把 stem 的 stride 调一下,不然 1/8 那层分辨率对不上。我一般会写一个BACKBONE_CFG字典把(c2, c3, c4)三个通道数记下来,构造时查表,比散落在代码里的魔法数字好维护。
导出 ONNX 是交付前的最后一步,动态轴一定要开,否则推理时换个尺寸就得重新导:
import torch model = FCN8s(num_classes=1, pretrained=False).eval().cpu() dummy = torch.randn(1, 3, 256, 512) torch.onnx.export( model, dummy, "fcn_pennfudan.onnx", input_names=["image"], output_names=["logits"], dynamic_axes={"image": {0: "n", 2: "h", 3: "w"}, "logits": {0: "n", 2: "h", 3: "w"}}, opset_version=17, ) # 导完必须比对数值,别信"导出成功"这四个字 import numpy as np, onnxruntime as ort sess = ort.InferenceSession("fcn_pennfudan.onnx", providers=["CPUExecutionProvider"]) ref = model(dummy).detach().numpy() got = sess.run(None, {"image": dummy.numpy()})[0] print("最大绝对误差:", np.abs(ref - got).max()) # 应小于 1e-3最后分享一个提高分数的技巧:推理时做水平翻转 TTA,把原图和翻转图的概率平均再二值化,IoU 通常能稳定涨 1~2 个点,代价只是推理时间翻倍,对这份只有 170 张图的数据集来说完全可以接受。阈值和 TTA 这两个开关我一般做成配置文件里的两个 bool,方便在验证集上对比开与关的收益。
从那以后我每次改完 Dataset 或增广,都会先跑一遍“取 8 个样本、把 img 和 mask 叠在一起存图、肉眼过一遍”的检查,再开始训练——这个动作只花一分钟,但省下的返工时间是以小时计的。希望帮到你。
本文还有配套的精品资源,点击获取