ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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_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), )) 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_size=1) self.align_aux = nn.Conv2d(ch_aux, out_ch, kernel_size=1) self.fuse = nn.Sequential( nn.Conv2d(out_ch * 2, out_ch, kernel_size=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) 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], dim=1) # 通道维拼接 return self.fuse(out)

align_main和align_aux把两路特征压到同一通道数out_ch,torch.cat在 dim=1 上拼接,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_size=2, stride=2) self.fusion = FusionBlock(skip_main, skip_aux, out_ch) self.conv = nn.Sequential( nn.Conv2d(out_ch * 2, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) 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, size=skip.shape[-2:], mode='bilinear', align_corners=False) x = torch.cat([x, skip], dim=1) 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, size=256): 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(p=0.5), A.RandomBrightnessContrast(p=0.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(image=img, mask=mask) 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, mask

A.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, smooth=1e-6): super().__init__() self.smooth = smooth def forward(self, logits, targets): # logits: [B, C, H, W], targets: [B, H, W] probs = torch.softmax(logits, dim=1) targets_onehot = nn.functional.one_hot(targets, num_classes=logits.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(dim=1) # 只算前景类的 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 groups=1, 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(mode='bilinear')加一个 3x3 卷积,或者确保kernel_size=2, stride=2这种整除组合。源码里如果用了kernel_size=3, stride=2,输出尺寸会不规整,需要额外 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(inplace=True), nn.Linear(ch // 2, 2), nn.Softmax(dim=1), ) def forward(self, f_main, f_aux): b, c, h, w = f_main.shape # 拼接后做全局池化 feat = torch.cat([f_main, f_aux], dim=1) 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_aux

AttentionFusion先把两路特征拼接做全局平均池化,再用两层全连接生成两个权重,softmax 保证和为 1。w_main和w_aux广播到空间维后加权求和。参数ch是单路特征通道数,fc中间层取ch//2是经验值,太大容易过拟合。这个模块替换掉原来的FusionBlock即可,其余结构不变。

验证收益的方法:在同一个数据集上跑两组实验,一组用固定融合,一组用注意力融合,其他超参完全一致。看验证集 Dice 和收敛 epoch 数。我的经验是注意力融合在前景占比低于 10% 的数据上提升明显,能涨 2 到 4 个点;但在前景占比高的数据上差异不大,反而多了一点计算量。所以值不值得上,先看你的类别分布。

还有一个实用技巧是冻结辅分支的前几层,只训练主分支和融合模块。这样在数据量少的时候能防止辅分支过拟合,训练也更稳。具体做法是在EncoderBranch的blocks里对前两个 block 设requires_grad=False,优化器里过滤掉这些参数。这个习惯帮我省过不少调参时间,尤其是在标注样本只有几百张的时候。希望帮到你。

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

RELATED READING

延伸阅读

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