简介:图像分割是计算机视觉的重要环节,U-Net凭借对称的编码-解码结构和跳跃连接,在医疗影像、遥感分析等场景中表现稳定。这份zip资源面向有Python基础、希望上手语义分割的开发者,内含21个文件,包括1个核心预测脚本、6张结果示意图、7张测试图片、2个GIF动态对比图及2份Markdown说明文档,压缩包仅5.6MB,便于快速学习与复现。资源围绕平滑拼接图像块的分割流程,展示了从数据准备、模型构建、损失函数选择到训练与预测的完整思路;smooth_tiled_predictions.py脚本可实现分块预测与平滑融合,解决大图直接推理时的边界拼接问题,辅助理解U-Net在实际项目中的落地方式。已有10157人学习下载,适合结合卫星图像分割案例,参考其中的图像预处理与结果可视化方法,快速迁移到自己的分割任务中。
1. UNet图像分割是什么:中小数据集上性价比最高的那个选择
你手里有一批无人机航拍图要做广告牌分割,有几百张地毯纹理图要自动抠出疵点,或者只有几十例标注好的医学影像要分病灶区域——这些任务落到我手里,现阶段的第一选择一定还是UNet。它不挑显卡、不吃ImageNet预训练、几十张标注图就能收敛出可用效果,在中小数据集和单机训练场景里,几乎没有第二个架构能这么省心。
我最早接触UNet是在遥感地物提取的任务上,那时拿到的样本只有三百多张,DeepLabV3+训练到一半就感受到显存和调参的压力,而UNet从第一天到收敛一直很平静。这里说的“UNet图像分割”,本质上是“给每个像素分类”:输入一张图,输出一张同尺寸的掩膜,每个像素的类别用不同的灰度值表示。工程落地时,它的价值在于网络本身够简明、训练循环够直白、问题定位够顺手。如果你已经会写Python推理循环,这篇从数据准备讲到推理部署,把参数边界和踩坑点一次拉通。
2. 拆开UNet看门道:编码器-解码器与跳跃连接为什么能保住边缘细节
2.1 U形结构到底在解决什么问题
UNet之所以叫“U”,是因为网络在结构图上呈现出左边长右边短的U形对称形态:左侧是编码器(encoder),负责把图像一步步下采样成越来越小的特征图;右侧是解码器(decoder),把特征图一步步上采样回原始输入分辨率。图像分割最难的地方在于:像素级分类需要空间位置足够精确,但分类本身的语义需要看到足够大的感受野。这两个诉求天然打架,UNet用“先降分辨率、再升分辨率”的方式把语义和精度串了起来。
这样做最直观的理由是显存和计算量。一张256×256的图,直接在原分辨率上做卷积建模每个像素的上下文,计算量会爆炸。UNet的做法是把图逐步变小:256→128→64→32,每一层通道数翻倍,最终在最底层的32×32特征图上拥有最大的感受野和最强的语义抽象能力。解码器再反向把它拉回256×256,完成逐像素的分类输出。这个“先压缩再展开”的思路,让UNet在同样显存下能跑更深、更宽的网络,这也是它比FCN系模型更好训练的原因之一。
UNet网络结构图网上很容易搜到,但你只需要记住几个关键数字:标准的UNet编码器做了4次下采样,每次分辨率减半、通道翻倍;解码器做4次上采样,最后接一个1×1卷积把通道数压到类别数。深度再加深时收益不明显,反而会拖慢训练;深度减到3层时对小目标分割影响较大,但显存吃紧时这个改动是可以接受的。我一般会优先保持4层,这是性能和显存最平衡的默认值。
2.2 跳跃连接:下采样丢了的地物细节,在解码器里捡回来
纯编码器-解码器结构存在一个天然缺陷:下采样过程中大量边缘、纹理等浅层信息被丢弃。你最终拿到的特征图语义强但分辨率低,上采样之后边缘往往是糊的。UNet的解决办法是跳跃连接(skip connection):编码器每一层下采样之前,把当前分辨率的特征图拷贝一份,在解码器对应层上采样后,把两份特征图拼接起来(concat)再继续卷积。
这里要区分两种最常见的做法。SegNet走的是“把池化索引传给解码器”的路线,信息量很有限;FCN的skip connection只做了简单相加,没有给解码器学习权重融合的机会。UNet用的是channel维度拼接,解码器可以在训练中自己学习“浅层细节占比多少、深层语义占比多少”。这个差异实际测试下来非常明显:同样训练50轮,跳跃连接做concat的UNet比只用相加的变体IoU能高出3到5个点,尤其对广告牌边缘这类高频信息更有效。所以写代码时不要偷懒用+代替torch.cat,语义完全不同。
2.3 用torchsummary验证网络结构搭没搭错
很多人第一次手写UNet会把skip connection的channel数对错,导致解码器拼接时维度报错。我的建议是:写模型先不要急着训,用torchsummary打印参数量和每层输出尺寸,30秒就能确认结构对不对。下面是示例代码和输出逻辑:
from torchsummary import summary import torch model = UNet(in_channels=3, num_classes=1) # 1类二值分割 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) summary(model, input_size=(3, 256, 256)) # 输出会逐层打印每层的输出shape,重点看: # - 每一个decode层concat后的通道数是否为 enc_channels + dec_channels # - 最后输出层的shape是否为 (batch, 1, 256, 256)参数说明:in_channels=3对应RGB输入,如果你用灰度图或四通道遥感影像,要改成对应值;num_classes=1表示输出单通道二值掩膜,如果是多类分割(比如背景、广告牌、文字三个类别),改成3。summary不需要真实数据,它用假张量过一遍前向网络,shape对不上立刻抛异常。这个检查也是我在写UNet模型改进时每次必做的动作,改完结构随手跑一下,能省出大量的debug时间。
3. 给UNet准备数据:从标注到预处理的一组可直接照抄的脚本
3.1 标注怎么做:从Labelme到灰度掩膜
UNet属于监督学习,需要成对的“原图+掩膜图”。第一个现实问题是标注数据从哪来。我的习惯是用Labelme画多边形(polygon),它能导出JSON格式的标注文件,再自己写脚本把JSON转成PNG灰度图。为什么不直接用Labelme的一键导出?因为默认导出的是彩色图,UNet训练时更适合直接加载灰度掩膜,颜色越少越不容易出错,索引冲突也更好排查。
标标注时的铁律是:每张原图必须对应一张尺寸完全相同的灰度掩膜图,背景灰度值为0,第一个类别灰度为1,第二个类别灰度为2,以此类推。有些新手会把背景写成255,这会让CrossEntropyLoss的类别索引直接错乱,属于最常见的低级翻车。下面是从Labelme JSON批量转掩膜的脚本:
import json import numpy as np import cv2 from glob import glob def json_to_mask(json_path, mask_path, label_map): with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) img_h, img_w = data["imageHeight"], data["imageWidth"] mask = np.zeros((img_h, img_w), dtype=np.uint8) for shape in data["shapes"]: label = shape["label"] points = np.array(shape["points"], dtype=np.int32) value = label_map.get(label, 0) # 未定义标签默认背景 cv2.fillPoly(mask, [points], color=value) cv2.imwrite(mask_path, mask) label_map = {"广告牌": 1, "文字": 2} # 按你的类别表修改 for jf in glob("labels/*.json"): out = jf.replace(".json", "_mask.png").replace("labels", "masks") json_to_mask(jf, out, label_map)逻辑说明:cv2.fillPoly负责把多边形内部填充成指定的类别灰度值,多类时逐标签填充;label_map是你自己维护的类别名到灰度值的映射表,训练和推理必须共用同一份表,改类别顺序会直接导致预测结果错位。脚本输出的掩膜是单通道8位图,读取时用cv2.imread(path, cv2.IMREAD_GRAYSCALE),注意不要用PIL默认的三通道加载方式,否则通道数会不匹配。
3.2 数据预处理脚本:归一化、HWC转CHW、滑动切片
拿到原始图和掩膜后,先做两件基础事:归一化和格式转换。PyTorch默认接受(C, H, W)的张量,而图像读取出来是(H, W, C),这一步必须翻转。归一化建议用mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225],这是ImageNet统计值,UNet虽然不依赖预训练权重,但用这个归一化数值能让通道数值分布更平缓,对收敛速度有正向帮助。
我一般不会用torchvision.transforms.Normalize单独处理,而是放在Dataset类里一起做,避免离线归一化占用额外磁盘空间。下面是我的Dataset代码骨架:
class SegmentationDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=256, stride=200): self.img_paths = sorted(glob(f"{img_dir}/*.jpg")) self.mask_paths = sorted(glob(f"{mask_dir}/*.png")) self.img_size = img_size self.stride = stride def __len__(self): return len(self.img_paths) * 4 # 每张图切成4个滑动窗口 def __getitem__(self, idx): img_idx = idx // 4 img = cv2.imread(self.img_paths[img_idx]) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(self.mask_paths[img_idx], cv2.IMREAD_GRAYSCALE) h, w = img.shape[:2] # 滑动窗口裁剪,6个窗口覆盖全图 windows = self._get_windows(h, w) # 这里按 idx % 4 取固定窗口,训练时配合RandomCrop做数据增强 y1, y2, x1, x2 = windows[idx % 4] img = img[y1:y2, x1:x2] mask = mask[y1:y2, x1:x2] img = img.astype(np.float32) / 255.0 img = (img - [0.485, 0.456, 0.406]) / [0.229, 0.224, 0.225] img = img.transpose(2, 0, 1) return torch.from_numpy(img).float(), torch.from_numpy(mask).long()参数说明:img_size=256决定了网络的输入分辨率,大多数UNet实现都以256为基准设计,改大或改小会影响下采样次数;stride=200表示窗口之间重叠56像素,重叠采样的意义在于让每个训练样本的上下左右都有一定的上下文过渡,避免裁剪线正好切在目标边缘上导致标注信息缺失。如果原始图是1024×1024,6个窗口基本能覆盖全部区域;如果图片特别大且目标是稀疏分布(如遥感影像),建议进一步缩小训练尺寸,别把小目标切成好几块碎样本。
3.3 数据增强的选择边界:翻转、旋转与颜色抖动
数据增强是在线做还是离线做,我的建议是在线做,也就是训练时对每次batch施加随机变换。离线把增强样本写进硬盘的做法会放大数据集体积几十倍,训练时面临IO瓶颈,翻车概率反而更高。在线增强用torchvision.transforms组合就行:
from torchvision import transforms augmentation = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.5), transforms.RandomRotation(degrees=15), # 小角度旋转 transforms.ColorJitter(brightness=0.2, contrast=0.2), ])这里必须强调一个边界:旋转角度不要设太大。广告牌和地毯这类有明确方向性的图像,旋转90度会把文字倒过来、把地毯纹理方向搞混,模型学到的是“各方向的图像都出现过,但标注语义没变”的错误先验,最后推理时在小角度倾斜的输入上反而失准。如果图像是医疗CT切片这类方向无关的类型,可以把旋转角度放宽到90度。另一个易忽略点是翻转操作要同时作用于图像和掩膜,代码里不要只增强原图而忘记同步变换mask,否则掩膜和图像直接错位,训练出来的模型预测结果就是一团乱码。
4. 把UNet训练起来:模型定义、损失函数与评估指标一条线讲完
4.1 用PyTorch实现UNet的完整模型定义
模型定义是整个训练链路的核心,我见过太多人从GitHub复制一个UNet就跑,遇到问题根本不知道从哪里下手改。下面是一份结构清晰、可以直接改的PyTorch实现,编码器解码器对称,跳跃连接全部用torch.cat完成:
import torch import torch.nn as nn class DoubleConv(nn.Module): """两层卷积+BN+ReLU的标准块""" def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels=3, num_classes=1, features=[64, 128, 256, 512]): super().__init__() self.encoders = nn.ModuleList() self.pool = nn.MaxPool2d(kernel_size=2, stride=2) self.decoders = nn.ModuleList() # 编码器通道逐层翻倍 for f in features: self.encoders.append(DoubleConv(in_channels, f)) in_channels = f self.bottleneck = DoubleConv(features[-1], features[-1] * 2) # 解码器:输入是跳跃连接拼接后的通道数 for f in reversed(features): self.decoders.append( nn.Sequential( nn.ConvTranspose2d(f * 2, f, kernel_size=2, stride=2), DoubleConv(f * 2, f) ) ) self.output = nn.Conv2d(features[0], num_classes, kernel_size=1) def forward(self, x): skip = [] for enc in self.encoders: x = enc(x) skip.append(x) x = self.pool(x) x = self.bottleneck(x) for idx, dec in enumerate(self.decoders): x = dec[0](x) # 上采样 x = torch.cat([x, skip[-idx - 1]], dim=1) # 跳跃连接拼接 x = dec[1](x) # DoubleConv return self.output(x)逻辑说明:features=[64, 128, 256, 512]是每一层编码器的输出通道数,bottleneck把底层通道翻倍到1024,解码器上采样后与对应的跳跃连接做channel维度拼接,所以nn.ConvTranspose2d输入通道写的是f*2(上一层通道数+跳跃连接通道数)。nn.ConvTranspose2d是转置卷积,只负责分辨率翻倍,真正的特征融合交给后面的DoubleConv。skip[-idx-1]的索引不能错,它对应的是编码器第len(encoders)-idx-1层的输出,写错了拼接就会报错。
4.2 损失函数怎么选:BCE、Dice与Focal的搭配
损失函数直接决定模型收敛方向和边界。二值分割最朴素的做法是BCEWithLogitsLoss,它对前景和背景一视同仁;如果是地毯疵点这类前景只占图像面积1%的极端场景,BCE会让模型学会“全部预测背景”,loss照样很低,预测结果全黑。我用得最顺手的是DiceLoss配合BCEWithLogitsLoss一起加权:
class CombinedLoss(nn.Module): def __init__(self, weight_bce=1.0, weight_dice=1.0): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.weight_bce = weight_bce self.weight_dice = weight_dice def forward(self, logits, targets): bce_loss = self.bce(logits, targets.float()) probs = torch.sigmoid(logits) smooth = 1e-6 intersection = (probs * targets).sum(dim=(2, 3)) dice = (2.0 * intersection + smooth) / (probs.sum(dim=(2, 3)) + targets.sum(dim=(2, 3)) + smooth) dice_loss = 1 - dice.mean() return self.weight_bce * bce_loss + self.weight_dice * dice_loss参数说明:weight_bce和weight_dice的默认值1:1适合多数场景;前景非常稀疏时把weight_dice提高到2.0,强制模型去关注那些占面积很小的目标。DiceLoss在数值上等于“1减Dice系数”,Dice越高损失越低,它的梯度天然对类别不均衡不敏感,所以和BCE搭配时既保留了BCE的稳定梯度又兼顾了Dice的区域相似性。多类分割时把BCEWithLogitsLoss换成CrossEntropyLoss,targets保持long类型,sigmoid改成softmax。
4.3 训练循环与监控:混合精度、学习率调度、early stopping
训练循环不需要写得花哨,重点放在监控指标和平滑收敛上。我习惯每5个epoch计算一次验证集IoU,并且用ReduceLROnPlateau做学习率自动衰减——这不是锦上添花,而是防止模型在loss平台期原地打转的重要机制。完整训练脚本:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="max", factor=0.5, patience=10, verbose=True ) best_iou = 0.0 for epoch in range(200): model.train() train_loss = 0.0 for imgs, masks in train_loader: imgs, masks = imgs.to(device), masks.to(device) optimizer.zero_grad() with autocast(): logits = model(imgs) loss = criterion(logits, masks.unsqueeze(1).float()) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() train_loss += loss.item() val_iou = evaluate(model, val_loader, device) # 自写评估函数 if val_iou > best_iou: best_iou = val_iou torch.save(model.state_dict(), "best_unet.pth") scheduler.step(val_iou)参数说明:AdamW比原生Adam更适配现代BN层,weight_decay=1e-5防止模型过拟合;autocast和GradScaler是PyTorch原生混合精度方案,能让显存占用下降约40%、训练速度提升约30%,在消费级显卡上效果明显。ReduceLROnPlateau的patience=10表示连续10轮IoU没有变好就降低学习率,factor=0.5则每次减半。best_iou的保存条件是验证集IoU创新高,这比固定轮数保存模型更可靠;训练结束后用best_unet.pth做推理,而不是用最后一个epoch的权重。
5. UNet训练避坑:五个一到训练就想骂人的常见问题
5.1 坑一:显存溢出OOM,batch_size调小之后精度还掉
现象:训练到第几轮突然报CUDA out of memory,把batch_size从8调到2之后能跑,但验证集IoU比之前低了两个点。原因:调小batch_size后,批归一化(BatchNorm)在batch内统计的均值和方差噪声变大,导致模型泛化能力下降。
解决:优先使用混合精度训练(上一节代码里的autocast),显存占用立减40%左右;还不够的话,改用torch.utils.data.DataLoader里的pin_memory=True和num_workers=4,减少数据读取带来的额外显存开销线索。最后再用gradient_accumulation做梯度累积,不要直接暴力缩小batch_size——梯度累积代码是在每个mini-batch上不更新参数,攒够4个batch的梯度再加和更新一次,等效于把batch_size放大4倍,这是我在显存紧张时最常用的替代方案。
5.2 坑二:前景太少,训练跑完预测全黑
现象:地毯疵点这类任务前景占比可能只有0.5%,训练20轮后loss下降正常,但验证集预测图全黑,或者只在图像边界冒出几个噪点。
原因:交叉熵损失被大面积背景主导,模型学到的函数就是“全部预测背景”;此时准确率高达99.5%,但没有任何实际分割能力。解决:把损失函数换成本文4.2节的CombinedLoss,并把weight_dice加到2.0或3.0;同时用torch.nn.functional.binary_cross_entropy_with_logits里的pos_weight参数给前景类别加权,这个参数表示“正样本权重/负样本权重”,设成前景像素数/背景像素数即可,我通常直接传一个统计好的标量。改完损失以后第一轮IoU可能只有0.1,不要慌,那是模型开始数像素级前景的信号,后面会快速抬升。
5.3 坑三:边缘锯齿与空洞,跳跃连接救不回来的细节
现象:预测出的广告牌边缘有锯齿感,或者小目标内部出现空洞;IoU不高,但肉眼觉得“差不多能接受”。原因:分割网络的深层特征丢失高频细节,而解码器的浅层特征占比不够;空洞的根本原因是小目标像素数太少,模型在最后上采样层丢失了部分响应。
解决:最有效的一招是用深监督,在主输出之外给解码器中间层各加一个辅助输出头,每层计算一次损失并加权相加,迫使解码器每一层都学会输出正确预测。其次把DoubleConv里的普通卷积换成分组卷积(groups=4),感受野不变但通道交互更精细。最后可以在损失函数里加一个boundary_loss,用torch.nn.functional.laplace卷积提取掩膜边界,让模型对边界像素给予更高权重——这属于UNet模型改进里投入产出比很高的操作。
5.4 坑四:推理结果出现棋盘格贴图感
现象:模型训练正常,但最终分割图上有一层明显的“马赛克”格子纹理,尤其在目标边缘附近最严重。原因:转置卷积(ConvTranspose2d)在上采样时会对部分区域重复叠加重叠权重,产生标准的不均匀覆盖伪影,也就是所谓的棋盘效应。
解决:把解码器里的nn.ConvTranspose2d替换为nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False)加一个普通Conv2d,推理结果会更干净;如果必须保留转置卷积,可以给上采样层换用nn.PixelShuffle(亚像素卷积),这是目前消除棋盘格效果最彻底的方案。我在实际部署时优先选Upsample + Conv,它有轻微模糊但不会引入伪影,配合后面的DoubleConv做平滑足够用。
5.5 坑五:数据增强过头,旋转90度后语义直接混乱
现象:训练集loss很低、验证集loss高得离谱,预测结果把本来水平的广告牌预测成倾斜的。原因:增强里用了RandomRotation(90),广告牌文字被转置后语义反转,模型被迫学习“倒着和正着的招牌是同类”,收敛方向被带偏。解决:把旋转角度限制在15度到20度以内,同时保留水平翻转但要关闭垂直翻转——对自然图像,垂直翻转会把“天空”翻到“地面”,语义彻底错乱。医用CT或遥感影像这类方向无关数据可以放宽限制,但自然场景图像这一条必须守住。这是我踩过最惨的一次坑,跑了两天发现数据增强方向和任务本身不兼容,只能推翻重训。
6. 最后一公里:TTA与ONNX导出让UNet真正能用起来
6.1 推理时增强TTA提升IoU
训练到收敛后,还有一个不用重训就能提点的技巧:推理时增强(Test-Time Augmentation,TTA)。做法是推理时把输入做几次变换,分别预测后再做反变换合并。最常见的组合是水平翻转和垂直翻转,每个输入图预测3次(原图、水平翻转、垂直翻转),然后对概率图取平均再取阈值。这个操作在广告牌和地毯分割上通常能带来0.5到1.5个点的IoU提升,且推理耗时只增加约3倍,很适合离线批量处理场景。
from torchvision import transforms def predict_with_tta(model, img, device): model.eval() base = transforms.ToTensor()(img).unsqueeze(0).to(device) flip_h = torch.flip(base, dims=[3]) flip_v = torch.flip(base, dims=[2]) with torch.no_grad(): p0 = torch.sigmoid(model(base)) p1 = torch.sigmoid(model(flip_h)) p2 = torch.sigmoid(model(flip_v)) p1 = torch.flip(p1, dims=[3]) p2 = torch.flip(p2, dims=[2]) prob = (p0 + p1 + p2) / 3.0 return (prob > 0.5).squeeze().cpu().numpy()参数说明:dims=[3]对应height维翻转前的水平翻转,dims=[2]是宽度维翻转前的垂直翻转;合并时torch.flip把预测结果翻回原方向后再求平均。如果你用的是多类分割,最后一步换成torch.argmax(prob, dim=1)而不是固定阈值0.5。
6.2 导出ONNX并量化部署
分割模型最终要跑到业务系统里,我不会直接用PyTorch做推理,而是导出ONNX再走ONNX Runtime或TensorRT,部署时的显存占用和延迟都会明显下降。导出ONNX最麻烦的是固定输入尺寸:如果你的在线推理输入可能是256或512,导出前先统一到训练时的固定尺寸,否则动态shape在ONNX Runtime上容易报错。
import torch.onnx dummy_input = torch.randn(1, 3, 256, 256).cuda() model.eval() torch.onnx.export( model, dummy_input, "unet_seg.onnx", opset_version=12, input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, )逻辑说明:opset_version=12可以兼容大部分ONNX Runtime版本,过旧或过新都会有算子兼容问题;dynamic_axes只打开了batch维度的动态化,H、W维度保持256×256固定,这是因为UNet的转置卷积和跳跃连接在可变尺寸上虽然可以成功导出,但实际推理时重复分配内存的开销会明显增加。
我最常用的部署链路是“导出ONNX + ONNX Runtime推理”,推理耗时比PyTorch原版快20%到50%,显存占用更低,且不需要在部署机上装PyTorch环境。量化版则用ONNX Runtime的INT8动态量化,在边缘设备上能再压一截模型体积,但要对精度损失做好心理准备,IoU掉1个点以内都可以接受。
这条技术路线从数据准备到部署跑通,踩过的坑大多集中在数据格式、类别权重和上采样方式这三个点上。希望我这些经历能让你第一次做UNet图像分割时少走一些弯路。最后提醒一句:训练时一定要用验证集IoU选模型,而不是训练集loss,这个习惯帮我避免过不知多少次发布前的翻车,希望帮到你。
本文还有配套的精品资源,点击获取