☰
FCN语义分割实战:拆全连接层、跳层上采样与小数据集IoU调优
2026/11/6 10:12:54 网站建设 项目流程

简介:面向图像分割与行人检测学习者的全卷积网络实践包,以Penn-Fudan Database为核心,适合希望理解FCN端到端像素级预测、复现训练与评估流程的初中级读者。资料共533个文件,压缩包约184.68MB,包含340个png图像与分割掩模、172个txt标注及训练日志、6个py训练/评估脚本、4个npy指标文件和4个xml配置等,覆盖数据读取、模型训练、指标统计与结果可视化等环节。已有507人学习下载。内容围绕FCN替换全连接层、上采样恢复分辨率、跳跃连接融合浅层细节等关键机制展开,并结合Penn-Fudan的70张街景标注图像、交叉熵损失与多组实验记录,可用于对照复现语义分割流程、观察损失及meanIU/meanPixel变化。脚本与日志中还保留了批大小、优化器、学习率调度等实验配置线索,便于复盘训练过程、积累排错和调参思路,对智能监控、自动驾驶等场景的像素级理解亦有参考价值。

1. 全卷积网络的赌注:为什么拆掉最后一层,分割就成立了

把分类网络末尾的全连接层扔掉、换成一串卷积,它突然就能吃任意尺寸的图,并吐出一张和原图逐像素对齐的热力图——这是全卷积网络(FCN)最反直觉的一步,也是它在一百七十来张图、三百多个人体的 Penn-Fudan Database 上还能训出可用轮廓的根本原因。很多人以为小数据集上分割必然翻车,其实真正决定成败的不是模型容量,而是「分割 = 每个像素做一次分类」这个改写:分类器早就在大规模数据上学过「什么像人」,你只需要教会它「在哪个位置」。搞懂这个改写为什么能 work,比调十个超参有用。

2. 全连接层为什么必须拆:从 flatten 到 1×1 卷积的等价改写

2.1 全连接层丢掉的不是参数,是坐标

以 VGG16 为例,224×224 的图走完 conv5_3 得到 7×7×512 的特征图,展平成 25088 维向量后接 4096 维全连接。这一步看似只是降维,实际把特征图的 (h, w) 两个索引彻底抹掉了,剩下的只是一个线性编号。于是权重矩阵的第 k 列隐含绑定「特征图第 i 行第 j 列」这个位置——位置信息没有被删除,而是被硬编码进了权重里。

后果有两个,且都致命。第一,网络只能处理固定分辨率输入,换个尺寸就得重新定义权重形状;第二,同一类目标出现在图像左上角和右下角时,需要完全不同的权重去激活,样本效率极低。对分类任务来说这尚可忍受(最终只取一个标签),对分割任务则是灾难:我们要的是每个像素各自的输出,而全连接层只肯给你一个向量。

传统绕法是用滑动窗口逐块送进分类器,每个像素跑一次前向。一张 500×300 的图就是十五万次推理,且相邻窗口的计算完全无法复用。这条路在工程上直接死掉。

2.2 1×1 卷积如何等价替换 FC:一笔参数量完全相同的账

关键观察是:当输入尺寸恰好是 7×7 时,一个 kernel 为 7×7、输入通道 512、输出通道 4096 的卷积,在 7×7 特征图上只滑一次,数学上就是全连接。两者参数量完全相同:

层全连接形式卷积形式参数量
fc625088 × 40967×7×512×4096102,760,448
fc74096 × 40961×1×4096×409616,777,216
score4096 × N1×1×4096×N4096 × N

参数一个没省,变的是「能不能滑」。换成卷积之后,喂一张 14×14 的特征图进去,同一个 7×7 卷积核会滑出 8×8×4096 的输出——权重没变,感受野没变,但输出保留了空间网格。这才是全卷积的本质:不是把全连接「压缩」成卷积,而是把「一次性矩阵乘」重写成「可复用、可平移的滑窗运算」。

提示:等价改写有个前提——7×7 卷积必须配 padding=3 才能在奇数尺寸特征图上保持尺寸不变。padding 写错一位,输出就会缩一圈,后面的上采样因子全部对不上账,这是最常见的静默错误。

2.3 骨干改造:把 classifier 换成 score head

下面这段改造以 torchvision 的 VGG16 为起点,保留了 conv1 到 conv5_3 的预训练权重,把 pool5 的步长改为 1,让特征图停在 1/16 而不是 1/32。

import torch import torch.nn as nn from torchvision import models class FCN16s(nn.Module): def __init__(self, num_classes=2, pretrained=True): super().__init__() weights = models.VGG16_Weights.DEFAULT if pretrained else None feats = list(models.vgg16(weights=weights).features.children()) # feats[0:23] -> conv1_1 ... conv4_3 + relu,输出 1/8,512 通道 # feats[23] -> maxpool4,输出 1/16 # feats[24:30] -> conv5_1 ... conv5_3 + relu,保持 1/16 self.block1 = nn.Sequential(*feats[:23]) self.pool4 = feats[23] self.block2 = nn.Sequential(*feats[24:30]) # 关键改动:pool5 不再降采样,只做 3x3/1 的局部聚合,保住 1/16 self.pool5 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1) # 用 7x7 卷积顶替 fc6,感受野与原始 VGG 一致 self.conv6 = nn.Conv2d(512, 4096, kernel_size=7, padding=3) self.conv7 = nn.Conv2d(4096, 4096, kernel_size=1) self.relu = nn.ReLU(inplace=True) self.drop = nn.Dropout2d(p=0.1) self.score_fr = nn.Conv2d(4096, num_classes, kernel_size=1) self.score_pool4 = nn.Conv2d(512, num_classes, kernel_size=1) # 严格 2 倍、8 倍上采样:k=4,s=2,p=1 -> (n-1)*2-2+4 = 2n self.up2_main = nn.ConvTranspose2d(num_classes, num_classes, 4, 2, 1, bias=False) self.up2_skip = nn.ConvTranspose2d(num_classes, num_classes, 4, 2, 1, bias=False) self.up8 = nn.ConvTranspose2d(num_classes, num_classes, 16, 8, 4, bias=False) def forward(self, x): h = self.block1(x) # 1/8 p4 = self.pool4(h) # 1/16,留给跳层 h = self.block2(p4) # 1/16 h = self.pool5(h) # 1/16,尺寸不变 h = self.drop(self.relu(self.conv6(h))) h = self.drop(self.relu(self.conv7(h))) s = self.up2_main(self.score_fr(h)) # 1/16 -> 1/8 s4 = self.up2_skip(self.score_pool4(p4)) # 1/16 -> 1/8 return self.up8(s + s4) # 1/8 -> 1/1

几个参数值得单独说清。pool5用 kernel 3、stride 1、padding 1,是为了在不损失分辨率的前提下维持感受野连续性,代价是显存占用上升,因为 1/16 的特征图比 1/32 大四倍。conv6用 7×7 而不是 3×3,是在模仿原版 fc6 的全局感受野,如果换成 3×3,网络会退化成只看局部纹理,人体这种大目标的内部一致性会变差。Dropout2d而不是Dropout,因为我们要按通道整片丢弃,逐元素丢弃在卷积特征图上会让空间结构变噪。

2.4 下采样倍数决定了你能走多远

整个网络的「输出步长」是所有 stride 的乘积。原始 VGG16 是 32,意味着 500×300 的输入最后只剩 16×10 的特征图,一个人体可能只占两三个格子——边界谈不上了。把 pool5 步长改成 1 之后是 16,池化 4 次得到 1/16,这已经是小数据集上比较务实的折中:再往上保分辨率(改 pool4 步长、用空洞卷积)参数量和显存都会上去,而 Penn-Fudan 里人体尺寸跨度很大,从几十像素到三百像素都有,1/16 对小目标偏粗,但还在可接受范围。

这里有个容易被忽略的换算:输入尺寸必须是 16 的整数倍。因为四次 stride 2 的池化会做向下取整,500 会被压成 31,再乘 16 回来变成 496,与标注差 4 个像素。这个偏移不会报错,只会让模型悄悄学着把边界往右下方挪。

3. 上采样与跳层连接:1/16 的语义图怎么还原成像素边界

3.1 反卷积不是「逆卷积」:可学习上采样的真实含义

ConvTranspose2d的名字误导了很多人。它并不是卷积的逆运算,而是一个「先在上采样后的稀疏网格里填零、再做一次普通卷积」的操作。stride=2 时,它在每两个输入元素之间插一个零,然后用卷积核把这些零和邻域值混合成输出。

它的数学形式决定了输出尺寸:(n-1)×s - 2p + k。要精确 2 倍,必须满足 k=4、s=2、p=1;要精确 8 倍,就是 k=16、s=8、p=4。差一个像素不算 bug,但会让整张掩码偏移,且不同输入尺寸下偏移量还不一样,属于典型的「跑得通但结果不对」的坑。

从机制上看,转置卷积有两大优势:权重可学习,网络能自己决定怎么插值;以及可以只用少量参数就把通道数和空间尺寸解耦(这里用 groups 概念可以进一步压缩,但小数据集上没必要)。代价是容易产生棋盘格伪影,后文会单独讲。

3.2 双线性核初始化:前十个 epoch 的命运由它决定

如果转置卷积的权重随机初始化,网络一开始输出的上采样结果就是随机噪声,梯度会先花大量步数去「学会怎么插值」这种本该免费得到的能力。原始 FCN 的做法是把卷积核初始化为双线性插值核——相当于让网络从一个「已知正确的插值器」出发,只学修正量。

import numpy as np import torch def bilinear_kernel(in_ch, out_ch, k): """构造 k x k 的双线性插值核;stride 必须等于 k // 2""" factor = (k + 1) // 2 center = factor - 1 if k % 2 == 1 else factor - 0.5 og = np.ogrid[:k, :k] filt = (1 - np.abs(og[0] - center) / factor) * (1 - np.abs(og[1] - center) / factor) weight = np.zeros((in_ch, out_ch, k, k), dtype=np.float32) if in_ch == out_ch: # 逐通道独立插值,不跨通道混合 weight[range(in_ch), range(out_ch)] = filt return torch.from_numpy(weight) # 以 2 倍上采样(k=4)为例,得到 [0.25, 0.75, 0.75, 0.25] 的一维核 layer = nn.ConvTranspose2d(2, 2, kernel_size=4, stride=2, padding=1, bias=False) layer.weight.data.copy_(bilinear_kernel(2, 2, 4))

in_ch == out_ch时的对角赋值是关键:每个输入通道只影响自己的输出通道,不允许跨通道混合,否则初始化就不再是「纯插值」。在 Penn-Fudan 这种只有前景/背景两类的任务上,双线性初始化能把收敛所需的 epoch 数压掉三成左右——这是我调过几次之后最稳定的一条经验。

3.3 FCN-16s 的融合逻辑:为什么低层特征不能省

如果只从 1/16 的 score 直接上采样 16 倍,边界会非常糊。原因很简单:1/16 特征图上的每个格子在原图上覆盖 16×16 像素,格子内部的细节在池化时就丢了,上采样只能插值,插值造不出高频信息。

跳层连接的思路是把 pool4 的输出(同样是 1/16,但只经过四层卷积、还没被 conv5 高度抽象)单独接一个 1×1 卷积转成类别分数,与主分支的分数按元素相加。

s = self.up2_main(self.score_fr(h)) # 主分支:语义强、位置糊 s4 = self.up2_skip(self.score_pool4(p4)) # 跳层:语义弱、位置准 fused = s + s4 # 同尺寸逐元素相加,不拼接

注意是「相加」不是「拼接」。相加要求两条分支的通道数都是类别数,好处是参数量最小、不需要额外的融合卷积;代价是网络无法学习两路信息的权重配比,只能靠后续上采样层的卷积隐式调节。拼接加 1×1 融合(U-Net 的做法)更灵活,但在小数据集上多出的参数反而容易过拟合。

尺度上还有个对应关系要理清:这里 pool4 输出相对原图是 1/16,主分支 score 也是 1/16,两边各自上采样 2 倍到 1/8 再相加,最后统一上采样 8 倍回原尺寸,总倍率 16,与骨干的输出步长严格一致。写错任何一处,输出和标注就对不上。

3.4 那个容易被忽略的特征图尺寸偏移

真实图像很少是 16 的整数倍。假设输入 501×321,四次池化后得到 31×20(向下取整),上采样 16 倍输出 496×320,比原图小。此时若直接把 496×320 的预测和 501×321 的标注做损失,CrossEntropyLoss会因为形状不匹配报错;若手动裁标注对齐,又会引入左上角对齐、右下角偏移的系统误差。

常见的处理思路是:训练时把图像 padding 到最近的 16 倍数,同时把标注的填充区域标成ignore_index,算损失时跳过,推理时再裁回原尺寸。

def pad_to_multiple(img, mask, m=16, ignore_index=255): _, h, w = img.shape ph, pw = (-h) % m, (-w) % m # 向上取整到 m 的倍数 img = torch.nn.functional.pad(img, (0, pw, 0, ph), mode="constant", value=0) mask = torch.nn.functional.pad(mask, (0, pw, 0, ph), mode="constant", value=ignore_index) return img, mask

ignore_index=255这个选择不是随意的:类别数只有 2,255 远离合法类别区间,不会被误当成某一类。同时损失函数里必须显式传ignore_index=255,否则这些填充像素会被当作「背景」参与训练——网络会学到一个非常糟糕的先验:图像右下角永远是背景。

4. 小数据集训练:170 张行人图上的损失设计与 IoU 口径

4.1 标注的组织方式与掩码合并

Penn-Fudan Database 的标注按实例给出,一张图里可能有两三个人体,每个实例一张二值掩码。分割任务要的是语义掩码,需要把它们合并成单通道。

import numpy as np import torch from PIL import Image class PedSegDataset(torch.utils.data.Dataset): def __init__(self, root, img_dir="images", mask_dir="masks", transform=None): import glob, os self.paths = sorted(glob.glob(os.path.join(root, img_dir, "*.png"))) self.root, self.mask_dir = root, mask_dir self.transform = transform def __len__(self): return len(self.paths) def __getitem__(self, i): import os p = self.paths[i] img = Image.open(p).convert("RGB") # 实例掩码按 <图名>_<序号>.png 组织,合并成 0/1 语义图 stem = os.path.splitext(os.path.basename(p))[0] import glob mpaths = sorted(glob.glob(os.path.join(self.root, self.mask_dir, stem + "_*.png"))) h, w = img.size[1], img.size[0] mask = np.zeros((h, w), dtype=np.uint8) for mp in mpaths: m = np.array(Image.open(mp).convert("L")) mask[m > 127] = 1 # 前景=1,背景=0 img = torch.from_numpy(np.array(img)).permute(2, 0, 1).float() / 255.0 mask = torch.from_numpy(mask).long() if self.transform: img, mask = self.transform(img, mask) return img, mask

两个细节。第一,掩码统一打上标签 1 而不是实例 ID:不同实例之间没有语义差别,给不同 ID 只会让网络去学「有两个人的图里左边那个人是类 2」,纯属噪声。第二,用> 127而不是> 0做二值化,是因为标注文件边缘常带抗锯齿产生的中灰像素,阈值太低会把一圈模糊的灰边也算进前景,让边界标签变成噪声。

4.2 前景占比只有几个百分点:交叉熵为什么会骗你

一张 500×300 的行人图,人体总面积通常在 5% 到 20% 之间,多人的图会高一些,但整体严重偏斜。不加权的交叉熵在这种情况下会被背景主导:模型只要把所有像素判成背景,损失就能降到 0.15 以下,而预测全背景的 IoU 是 0,可 loss 曲线看起来漂亮得让人放心。

三种处理方式在实践中效果递减:

  • 类别权重。给前景 5~10 的权重,直接改损失函数的weight参数。简单、稳、几乎无副作用,是首选。
  • Dice 或 Tversky 损失。直接优化区域重叠度,对偏斜不敏感,但训练前期梯度不稳,建议与交叉熵按 1:1 混合。
  • 在线难例挖掘。只回传损失最高的若干像素,实现复杂,在这个数据量级上收益不明显。
# 类别权重按前景/背景频率的倒数近似,裁剪到合理区间 w = torch.tensor([1.0, 8.0]) # [背景, 前景] criterion = torch.nn.CrossEntropyLoss(weight=w, ignore_index=255) # 可选的 Dice 混合,smooth 防止空掩码时除零 def dice_loss(logits, target, smooth=1.0): prob = torch.softmax(logits, dim=1)[:, 1] tgt = (target == 1).float() inter = (prob * tgt).sum() return 1 - (2 * inter + smooth) / (prob.sum() + tgt.sum() + smooth) loss = criterion(logits, target) + 0.5 * dice_loss(logits, target)

4.3 用混淆矩阵算 IoU,别用像素准确率

像素准确率在这个任务上没有意义。前面说过,全背景预测也能拿到 85% 以上的准确率,但它毫无用处。真正该盯的是 IoU:交集除以并集,只在前景上算。

def confusion_matrix(pred, target, num_classes=2, ignore_index=255): keep = target != ignore_index pred, target = pred[keep], target[keep] idx = target * num_classes + pred cm = torch.bincount(idx, minlength=num_classes ** 2) return cm.reshape(num_classes, num_classes) def iou_from_cm(cm): cm = cm.float() inter = torch.diag(cm) union = cm.sum(0) + cm.sum(1) - inter return (inter / union.clamp(min=1)).tolist() # [背景 IoU, 前景 IoU]

要注意union.clamp(min=1)这一手:某张图如果没有人体,前景的 union 就是 0,直接除会得到 NaN,一个 NaN 污染整个 epoch 的均值。这类问题在验证阶段特别隐蔽,因为 loss 是正常的,只有指标会突然变成 nan。

另一个口径问题是:验证集指标应该按图像平均还是按像素累计。按像素累计会让大图、多人图主导结果;按图像平均则给小图同等权重。在只有一百多张图的情况下,我倾向于按像素累计,因为最终部署时关心的就是整幅图的整体质量,且这个口径更稳定。

4.4 冻结策略与增强:小数据集上真正起作用的部分

真正决定成败的不是网络结构,是「哪些参数允许动」。预训练 VGG16 的浅层学到了边缘、颜色梯度、纹理,这些在行人分割上完全通用,没有理由去动它。比较稳的方案是:

  • block1(conv1_1 到 conv4_3)冻结,学习率设 0。
  • block2(conv5_x)用 1/10 的基础学习率微调。
  • conv6、conv7、score、上采样层用基础学习率,并从零训练(Xavier 初始化)。

基础学习率用 SGD momentum 0.9、1e-4 起步比 Adam 更稳,因为 Adam 的自适应步长会让新随机初始化的 conv6 前期更新过猛,容易把预训练特征带崩。如果一定要用 Adam,把 conv6 的学习率再降一半。

增强方面,Penn-Fudan 的图像尺寸差异很大,短边从两百多到四百多不等。有效组合是:随机缩放短边到 [320, 512]、随机裁剪 448×448、随机水平翻转。颜色抖动可以加,但幅度要小——行人分割主要靠形状,颜色是干扰项。垂直翻转不要用,人不会倒立行走,这个先验是免费的。

5. 排查清单:五类让 IoU 卡在 0.3 不动的高频故障

5.1 损失在降但掩码全黑:标签与损失函数的三处失配

现象:训练 loss 从 0.6 平稳降到 0.1,验证集掩码几乎全黑,前景 IoU 是 0.02 左右,偶尔蹦出一小块。

原因:类别权重没生效。常见于两种写法错误——一是给CrossEntropyLoss传了weight但张量没搬到 GPU,PyTorch 会静默按 CPU 张量处理并在某些版本下退化为无权重;二是自己写了加权公式但把前景权重乘在了 log_softmax 之后而不是损失上,等于没加权。

解决:先做一次「全背景基线」验证——手动让模型输出全 0,看 loss 是多少。如果训练 loss 低于这个基线,说明模型学到的还不如常量预测,权重肯定没起作用。把weight张量显式.to(device),并打印一次 loss 的前景项和背景项分量。

现象:前景 IoU 冲高到 0.7 后又慢慢掉回去。

原因:ignore_index没传给损失函数,padding 区域被当成背景。前几个 epoch 模型还在拟合真实区域,随着 padding 占比的梯度累积,它逐渐学会「图像边缘是背景」这个错误先验,把人走到画面边缘的样本全判黑。

解决:确认CrossEntropyLoss(ignore_index=255)和标注里 padding 值 255 一致;用(target == 255).float().mean()打印一次填充比例,超过 5% 就要重新检查裁剪策略。

5.2 轮廓整体错位:尺寸对齐与上采样因子的账

现象:预测的人体形状是对的,但整体往右下平移了十几像素,越靠边偏移越明显。

原因:上采样倍率与骨干输出步长不一致。比如骨干实际是 1/16,但上采样用了 32 倍,输出尺寸是输入的 2 倍,训练时靠interpolate强行缩回原尺寸,等于给预测施加了一个非整数倍的缩放,对齐关系就崩了。

解决:在 forward 里打印每一步特征图的shape,从输入一路核对到输出,确认H_out == H_in且W_out == W_in。用整数倍的k=2s, p=s/2组合,不要用output_padding硬凑。

现象:不同尺寸的输入,偏移量不一样,小图偏 1~2 像素,大图偏 5~6 像素。

原因:池化向下取整累积误差。501 宽的图经过四次 stride 2 变成 31,回来是 496,差 5 像素,这个差值随输入尺寸线性变化。

解决:训练和推理统一 pad 到 16 的倍数(推理时裁回原尺寸),不要依赖interpolate做后处理对齐。

5.3 指标抖动与棋盘格:骨干、初始化与转卷积的副作用

现象:换成带 BatchNorm 的骨干(比如 ResNet 版本)后,验证 IoU 每个 epoch 上下跳 0.15,训练 loss 却很平滑。

原因:小数据集上 batch size 只能开到 2~4,BN 用这么小的批量估计均值和方差,统计量本身就噪声极大;训练模式与推理模式下的统计量差异会让验证结果剧烈摆动。VGG16 没有 BN 所以不会遇到,一换骨干就暴露。

解决:冻结 BN 的 running stats(module.eval()且不更新),或者把 BN 换成 GroupNorm。分割任务对批量统计本来就不敏感,GroupNorm 在这个场景几乎没有精度损失。

现象:输出的掩码上有规律的网格状明暗条纹,放大看像棋盘。

原因:转置卷积的核在重叠区域不均匀。stride 2、k=4 时,某些输出位置被 1 个核覆盖,某些被 4 个覆盖,如果权重是随机初始化的,重叠度不同就造成幅值不均。

解决:双线性初始化基本能消掉大部分条纹;如果仍有残留,改用「最近邻上采样 + 3×3 卷积」,彻底避开重叠求和,代价是多一点参数。

现象:训练 loss 前 50 步直接爆炸成 nan。

原因:conv6/conv7 是随机初始化的 4096 通道大层,配上 1e-3 的学习率,第一次更新就能把预训练特征冲垮。

解决:conv6/conv7 用 Xavier 初始化,学习率单独降到 1e-5,并加 200 步的 warmup。这是血泪经验——nan 出现的位置往往在第三四个 batch,光看最后一行日志根本追不到源头。

6. 设计取舍:FCN 的粗糙边界为什么仍是正确的起点

FCN 的边界确实糊,这一点不用辩护。它糊的根源是结构性的:1/16 的特征图上每个格子覆盖原图 256 个像素,池化阶段丢掉的高频信息,上采样再聪明也造不回来,只能插值。跳层连接缓解了一部分,但 pool4 本身也经过四次下采样,精度上限就锁在那里。

理解了这一点,后面所有分割网络的演进路线就都顺了。它们本质上是在回答同一个问题:怎么在不牺牲感受野的前提下少丢分辨率。

方案保分辨率的手段相对 FCN 的代价适合场景
FCN-8s多加一层 pool3 跳层显存涨、融合层参数变多边界要求高、数据量小
空洞卷积池化步长改 1,卷积加 dilation计算量成倍上升,网格效应中大规模数据
编码-解码解码器逐级融合,学上采样参数多,小数据易过拟合数据充足
后处理(条件随机场)用像素相似度修正边界推理慢,超参敏感只做离线精修

在小数据集上,我一般不会一上来就上空洞卷积。空洞卷积的收益需要足够数据才能兑现,而 Penn-Fudan 这个量级,先把预训练、冻结策略、类别权重、上采样对齐这四件事做对,往往比换结构涨得多。真正的顺序应该是:先保证 pipeline 没有静默错误(尺寸、ignore_index、IoU 口径),再调损失和增强,最后才动结构。

验证方法上,别只信单一划分。一百七十张图按 8:2 划分,验证集只有三十几张,一次 IoU 波动 0.05 完全可能是抽样噪声。更靠谱的做法是做 5 折交叉验证,看各折 IoU 的均值和标准差;标准差超过 0.06 就说明模型对数据划分太敏感,此时报告单次结果没有意义。另外强烈建议单独看一眼前景 IoU 和边界区域的 IoU(把标注腐蚀/膨胀一圈取差集),后者能直接暴露上采样精度问题,比整体 IoU 敏感得多。

进阶方向上,如果预算允许,优先试两件事:一是多尺度推理(原图 + 0.75× + 1.25×,把 softmax 概率平均后再取 argmax),几乎零成本,通常能涨 1~2 个点;二是把损失换成交叉熵加 Dice 的混合,在小目标上收益明显。

从那以后我每次搭分割 pipeline,都强制走一遍「先打印每层 shape、再打印全背景基线 loss、最后用 5 折验证」这三步,哪怕代码是从上一个项目抄过来的——因为这三步抓到的 bug,全都是不会报错只会悄悄拉低指标的那种。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询