资讯详情

Y-Net源码学习:双编码器图像分割实战与避坑指南

📅 2026/10/12 18:47:05 | 华诺云谱 👁 阅读
Y-Net源码学习:双编码器图像分割实战与避坑指南
简介这份资源是Y-Net光声成像重建网络的完整源代码包面向从事医学图像处理、深度学习重建研究的学生与开发者帮助理解如何用神经网络替代传统傅立叶逆变换或有限元方法实现快速、高质量的光声图像重建。压缩包共9个文件以8个Python脚本和1份Markdown文档为主涵盖网络模型定义、训练主程序、数据加载与工具模块整体约14KB体积轻量便于快速阅读与二次开发。目前已有500人学习下载适合作为入门深度学习医学成像的实战参考。通过研读代码读者可以掌握Y-Net在U-Net基础上针对光声成像的架构调整理解卷积、池化、上采样与跳跃连接如何协同捕捉多尺度信息并借助README了解运行流程与参数配置进而迁移到OCT或超声成像等相近任务中也可基于现有模块调整网络结构、复现训练过程或参与开源改进。1. Y-Net 源码学习从双编码器结构到可复现的图像分割方案第一次接触 Y-Net 的人多半是在找医学图像分割或遥感变化检测的轻量方案时撞见它的。它最反直觉的一点是主干不是一条路走到底而是两条编码分支并行最后再拼到一起解码。很多人把它当成 U-Net 的简单变体结果读源码时卡在特征融合那一层不知道两路特征到底怎么对齐、通道怎么裁。这篇笔记就围绕 Y-Net 的源代码学习展开把双编码器结构、融合模块、损失函数和训练配置逐层拆开给出能直接跑通的最小实现。适合已经写过 U-Net、想搞懂多分支分割网络怎么落地的人也适合需要把 Y-Net 改到自己数据集上的从业者。下面所有代码都是可复现的骨架不依赖任何特定仓库你照着填数据路径就能跑。2. Y-Net 的双编码器到底在编什么结构拆解与选型理由Y-Net 的核心设计动机来自一个很实际的问题单一编码器在同时处理纹理细节和语义轮廓时容易顾此失彼。比如医学图像里病灶边界是低频语义但血管纹理是高频细节一条卷积链很难同时保住两者。Y-Net 的做法是开两条编码路径一条走常规的逐层下采样另一条走更浅或不同感受野的路径最后在解码阶段融合。理解这一点读源码时就不会把两条分支当成冗余。2.1 两条编码分支的职责划分常见做法是主分支用标准卷积堆叠每层下采样一次负责提取语义特征辅分支用较少的通道数或不同的卷积核尺寸负责保留空间细节。两条分支的深度可以相同也可以辅分支更浅。源码里通常用两个独立的Encoder类实例化或者用一个nn.ModuleList管理。关键参数是每层的输出通道数主分支一般是[64, 128, 256, 512]辅分支可以是[32, 64, 128, 256]。这样设计的好处是融合时通道数不对等需要一次 1x1 卷积做对齐这也是很多人读源码时第一个卡点。import torch import torch.nn as nn class EncoderBranch(nn.Module): def __init__(self, in_ch, channels): super().__init__() # channels 例如 [64, 128, 256, 512] layers [] for out_ch in channels: layers.append(nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, kernel_size3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), )) in_ch out_ch self.blocks nn.ModuleList(layers) self.pool nn.MaxPool2d(2) def forward(self, x): feats [] for block in self.blocks: x block(x) feats.append(x) # 保存每层输出用于跳跃连接 x self.pool(x) return feats这段代码里feats列表保存了每个尺度下的特征图解码时要用。pool放在 block 之后意味着特征图先卷积再下采样。参数channels决定了每层通道数主分支和辅分支传不同的列表即可。注意in_ch在循环里被更新保证下一层输入通道匹配。2.2 融合模块的三种常见写法两条分支的特征要在解码前或解码中合并。源码里常见三种融合方式逐元素相加、通道拼接后 1x1 卷积、以及注意力加权融合。逐元素相加要求通道数完全一致所以辅分支通道数必须和主分支对齐这会削弱双分支的灵活性。通道拼接更通用拼接后通道数翻倍再用 1x1 卷积压回目标通道数。注意力加权融合则是给两路特征各算一个权重图按像素加权求和计算量稍大但效果通常更稳。class FusionBlock(nn.Module): def __init__(self, ch_main, ch_aux, out_ch): super().__init__() # 先对齐通道再拼接融合 self.align_main nn.Conv2d(ch_main, out_ch, kernel_size1) self.align_aux nn.Conv2d(ch_aux, out_ch, kernel_size1) self.fuse nn.Sequential( nn.Conv2d(out_ch * 2, out_ch, kernel_size1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, f_main, f_aux): f_main self.align_main(f_main) f_aux self.align_aux(f_aux) out torch.cat([f_main, f_aux], dim1) # 通道维拼接 return self.fuse(out)align_main和align_aux把两路特征压到同一通道数out_chtorch.cat在 dim1 上拼接fuse里的 1x1 卷积把通道数从out_ch*2降回out_ch。参数out_ch一般取主分支该层的通道数。如果显存紧张可以把out_ch减半但要注意后续解码器的输入通道也要同步改。2.3 解码器如何利用两路跳跃连接解码器每一层要接收三个输入上一层的上采样结果、主编码器对应层的特征、辅编码器对应层的特征。常见做法是先把两路编码特征融合再和上采样结果拼接然后过卷积块。这里有个顺序坑如果先拼接再融合通道数会爆炸先融合再拼接更省显存。源码里通常用一个DecoderBlock封装这个逻辑。class DecoderBlock(nn.Module): def __init__(self, in_ch, skip_main, skip_aux, out_ch): super().__init__() self.up nn.ConvTranspose2d(in_ch, out_ch, kernel_size2, stride2) self.fusion FusionBlock(skip_main, skip_aux, out_ch) self.conv nn.Sequential( nn.Conv2d(out_ch * 2, out_ch, kernel_size3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x, f_main, f_aux): x self.up(x) skip self.fusion(f_main, f_aux) # 如果尺寸不匹配用插值对齐 if x.shape[-2:] ! skip.shape[-2:]: x nn.functional.interpolate(x, sizeskip.shape[-2:], modebilinear, align_cornersFalse) x torch.cat([x, skip], dim1) return self.conv(x)up用转置卷积上采样fusion融合两路跳跃特征interpolate处理尺寸不匹配的边界情况。参数in_ch是上一层解码输出通道skip_main和skip_aux要和编码器对应层通道一致。这个块堆叠四次就能还原到原图分辨率。3. 把 Y-Net 跑起来数据管道、损失函数与训练循环结构清楚之后下一步是让它真正跑起来。Y-Net 源码学习里最容易翻车的不是模型定义而是数据管道和损失函数。分割任务里类别不平衡是常态损失函数选错会让模型直接输出全背景。下面按数据加载、损失、训练循环三步走每步都给可抄的代码。3.1 数据集与增强管道的搭建假设你的数据是图像加掩码的配对格式常见做法是自定义Dataset在__getitem__里同步做图像和掩码的增强。注意几何变换必须同步颜色变换只对图像做。Y-Net 输入尺寸通常是 256x256 或 512x512太大显存吃不消太小细节丢失。import os import cv2 import numpy as np from torch.utils.data import Dataset import albumentations as A class SegDataset(Dataset): def __init__(self, img_dir, mask_dir, size256): self.img_dir img_dir self.mask_dir mask_dir self.names sorted(os.listdir(img_dir)) self.size size # 几何变换同步颜色变换只对图像 self.aug A.Compose([ A.Resize(size, size), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(p0.3), ]) def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] img cv2.imread(os.path.join(self.img_dir, name)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask_name name.replace(.jpg, .png) mask cv2.imread(os.path.join(self.mask_dir, mask_name), 0) out self.aug(imageimg, maskmask) img out[image].astype(np.float32) / 255.0 mask out[mask].astype(np.int64) img torch.from_numpy(img).permute(2, 0, 1) mask torch.from_numpy(mask) return img, maskA.Compose里几何变换会自动同步图像和掩码RandomBrightnessContrast只影响图像。mask用最近邻插值避免产生中间类别值。参数size要和模型输入一致img_dir和mask_dir里的文件名要能对应上否则会读到空掩码。3.2 损失函数选型Dice 与交叉熵的组合分割任务里纯交叉熵在类别不平衡时会被背景主导纯 Dice 在训练初期梯度不稳定。常见做法是两者加权组合权重一般取 0.5 比 0.5 或 0.3 比 0.7。Y-Net 源码里通常实现一个DiceLoss再和CrossEntropyLoss相加。class DiceLoss(nn.Module): def __init__(self, smooth1e-6): super().__init__() self.smooth smooth def forward(self, logits, targets): # logits: [B, C, H, W], targets: [B, H, W] probs torch.softmax(logits, dim1) targets_onehot nn.functional.one_hot(targets, num_classeslogits.shape[1]) targets_onehot targets_onehot.permute(0, 3, 1, 2).float() intersection (probs * targets_onehot).sum(dim(0, 2, 3)) union probs.sum(dim(0, 2, 3)) targets_onehot.sum(dim(0, 2, 3)) dice (2 * intersection self.smooth) / (union self.smooth) return 1 - dice.mean() # 组合损失 ce nn.CrossEntropyLoss() dice DiceLoss() def criterion(logits, targets): return 0.5 * ce(logits, targets) 0.5 * dice(logits, targets)DiceLoss里先做 softmax 再和 one-hot 目标算交并比smooth防止除零。组合损失里权重 0.5 比 0.5 是起点如果验证集 Dice 波动大可以把 Dice 权重降到 0.3。注意targets不能是 one-hot交叉熵要求类别索引。3.3 训练循环与验证指标训练循环里要记录损失和验证集 Dice方便判断是否过拟合。优化器常用 Adam学习率 1e-3 起步配合余弦退火。批大小根据显存调256 尺寸下 8 或 16 都行。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for img, mask in loader: img, mask img.to(device), mask.to(device) optimizer.zero_grad() logits model(img) loss criterion(logits, mask) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader) torch.no_grad() def validate(model, loader, device): model.eval() dice_sum, count 0, 0 for img, mask in loader: img, mask img.to(device), mask.to(device) logits model(img) pred logits.argmax(dim1) # 只算前景类的 Dice for c in range(1, logits.shape[1]): p (pred c) t (mask c) inter (p t).sum().item() union p.sum().item() t.sum().item() if union 0: dice_sum 2 * inter / union count 1 return dice_sum / max(count, 1)train_one_epoch里注意optimizer.zero_grad()的位置放在前向之前。验证时只算前景类 Dice背景类通常不关心。count防止某些批次没有前景导致除零。学习率调度器可以在每个 epoch 后调用scheduler.step()。4. Y-Net 源码学习中的避坑与排查清单这一章是我自己踩过的坑按现象、原因、解决三段写。很多问题不是模型本身而是数据或配置的细节。4.1 融合层通道数不匹配导致报错现象运行时报RuntimeError: Given groups1, weight of size [64, 128, 3, 3], expected input[1, 96, 64, 64] to have 128 channels。原因主分支和辅分支在某一层的输出通道数不一致融合时没有对齐。解决在FusionBlock里强制用 1x1 卷积把两路通道压到同一值或者检查编码器配置里两条分支的channels列表是否在对应层一致。我一般会在融合前打印两路特征形状确认无误再往下走。4.2 验证集 Dice 一直不涨现象训练损失在降但验证 Dice 卡在 0.3 左右。原因常见是数据增强过度或者损失函数权重偏向交叉熵导致模型只学背景。解决先把增强关掉跑一遍确认模型能过拟合小数据集再把 Dice 权重提到 0.7 试试。另外检查掩码的类别值是否从 0 开始连续如果有 255 这种未映射值交叉熵会报错或学偏。4.3 显存溢出但批大小已经调到 1现象CUDA out of memory即使 batch size 设为 1。原因Y-Net 双编码器参数量接近单编码器的两倍加上融合模块的中间特征显存占用比 U-Net 高不少。解决把输入尺寸从 512 降到 256或者把辅分支通道数减半。还可以用混合精度训练torch.cuda.amp能省三成左右显存。如果还不行检查是否有特征图在循环里被意外保留比如feats列表没释放。4.4 转置卷积产生棋盘伪影现象预测掩码边缘有规律性的网格状伪影。原因ConvTranspose2d的 stride 和 kernel size 不匹配时会出现棋盘效应。解决把上采样换成nn.Upsample(modebilinear)加一个 3x3 卷积或者确保kernel_size2, stride2这种整除组合。源码里如果用了kernel_size3, stride2输出尺寸会不规整需要额外 padding。4.5 多卡训练时 BatchNorm 统计量不同步现象单卡验证正常多卡训练时验证指标崩掉。原因nn.BatchNorm2d默认只统计当前卡上的批次多卡时统计量有偏。解决把 BatchNorm 换成SyncBatchNorm用nn.SyncBatchNorm.convert_sync_batchnorm(model)转换。或者改用 GroupNorm对小批量更稳。这个坑在双分支结构里更明显因为每张卡上的有效样本更少。5. 进阶技巧用注意力融合替换固定权重并验证收益基础版 Y-Net 的融合是固定权重的拼接或相加进阶做法是让网络自己学两路特征的权重。常见实现是加一个通道注意力或空间注意力模块对两路特征各算一个 0 到 1 的权重图再加权求和。这样在细节和语义冲突的区域网络能自动偏向更可靠的那一路。class AttentionFusion(nn.Module): def __init__(self, ch): super().__init__() # 用全局平均池化生成通道注意力 self.fc nn.Sequential( nn.Linear(ch * 2, ch // 2), nn.ReLU(inplaceTrue), nn.Linear(ch // 2, 2), nn.Softmax(dim1), ) def forward(self, f_main, f_aux): b, c, h, w f_main.shape # 拼接后做全局池化 feat torch.cat([f_main, f_aux], dim1) gap nn.functional.adaptive_avg_pool2d(feat, 1).view(b, -1) weights self.fc(gap) # [B, 2] w_main weights[:, 0].view(b, 1, 1, 1) w_aux weights[:, 1].view(b, 1, 1, 1) return f_main * w_main f_aux * w_auxAttentionFusion先把两路特征拼接做全局平均池化再用两层全连接生成两个权重softmax 保证和为 1。w_main和w_aux广播到空间维后加权求和。参数ch是单路特征通道数fc中间层取ch//2是经验值太大容易过拟合。这个模块替换掉原来的FusionBlock即可其余结构不变。验证收益的方法在同一个数据集上跑两组实验一组用固定融合一组用注意力融合其他超参完全一致。看验证集 Dice 和收敛 epoch 数。我的经验是注意力融合在前景占比低于 10% 的数据上提升明显能涨 2 到 4 个点但在前景占比高的数据上差异不大反而多了一点计算量。所以值不值得上先看你的类别分布。还有一个实用技巧是冻结辅分支的前几层只训练主分支和融合模块。这样在数据量少的时候能防止辅分支过拟合训练也更稳。具体做法是在EncoderBranch的blocks里对前两个 block 设requires_gradFalse优化器里过滤掉这些参数。这个习惯帮我省过不少调参时间尤其是在标注样本只有几百张的时候。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑