ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

InDuDoNet复现指南:双域展开网络低剂量CT重建的PyTorch实现

InDuDoNet复现指南:双域展开网络低剂量CT重建的PyTorch实现 简介针对InDuDoNet模型的论文复现需求这份Python源码包提供了完整的工程化实现主要面向研究深度学习模型复现、CT图像分割等方向的研究者与开发者。包内共61个文件包含44个Python脚本、9个YAML配置文件、4个文本文件、2个CSV文件、1个Git忽略文件、1个MAT文件整体压缩后约918KB。Python脚本覆盖训练、测试、推理、网络结构及工具函数等模块YAML文件负责模型与训练参数配置CSV用于实验数据记录MAT文件则存放矩阵数据便于科学计算与可视化。已有385人学习下载。资源目录结构清晰完整呈现了InDuDoNet、InDuDoNet_Plus以及针对Deeplesion、RatFemur等数据集的训练/推理流程可帮助读者快速搭建环境、理解复现思路也为后续算法改进与实验对比提供了可靠基础。1. InDuDoNet 复现的起点双域展开网络究竟在解决什么如果你的算法岗面试里被问过“低剂量 CT 重建怎么做”大概率绕不开 DuDoNet 这类双域网络。InDuDoNet 在思路上是它的后续演进把传统迭代重建算法的每一次迭代展开成神经网络的一层同时在投影域sinogram和图像域之间来回切换。用大白话说就是在角度稀疏、噪声拉满的投影数据上让网络一边修投影、一边修图像而不是走“先重建后去噪”的老路。复现这类源码的难度不在 PyTorch 基础 API而在于三件事双域输入输出的维度怎么对齐、数据一致性这一层要不要加、以及训练时的数值范围是不是匹配 CT 值。下面用一个可以直接改着用的 Python 实现骨架把这三件事讲透。适合正在复现论文、想移植到医疗数据集、以及准备把展开网络用在自己项目上的工程向读者。2. 从论文到代码InDuDoNet 的双域结构与 PyTorch 模块拆解2.1 为什么同时处理投影域和图像域CT 重建的难点在于投影数据不完整或含噪。如果在图像域直接做后处理网络很容易把噪声“擦”出伪影如果在投影域单独去噪又无法感知图像结构信息。双域网络的做法是把两个域变成两条互补的支路投影域网络负责修正 sinogram 里的缺失角度和噪声图像域网络负责修正重建图像里的条纹伪影和组织边缘。这两个域的交替不是两次独立推理而是被放进一个迭代展开结构中共享同一个损失函数去指引两者协同。InDuDoNet 这类模型和普通 U-Net 的最大区别就在这里它不是一个“端到端映射”而是模拟了迭代重建算法的优化过程。每一轮迭代都会把中间结果再送进投影与反投影变换相当于给网络装了一个“物理约束器”让它推理出的 sinogram 和图像域特征能对得上。这个设计直接决定了后面代码里你需要同时维护两个数据流而不是只关心一张图。2.2 展开机制把迭代算法变成网络层传统迭代重建里最常见的更新式是x_{k1} x_k λ·A^T(y - A·x_k)其中A是前向投影算子A^T是反投影算子λ是步长。InDuDoNet 的展开思路就是让网络学习这个更新式里“该往哪个方向修正”的部分用一个小型子网络替代固定的梯度项同时保留A和A^T作为可微分层参与训练。具体到实现每个展开阶段通常包含三步先对 sinogram 做一次残差修正然后通过一个重建层滤波反投影或可学习的投影模块把 sinogram 转到图像域再做一次图像域残差修正。多个阶段串联后一个阶段能修复前一个阶段留下的误差。这里最容易踩坑的是阶段间的参数共享论文里可能对每个阶段用独立参数有的复现为了省显存共享权重结果性能下降明显。我的经验是先用共享参数跑通再一个个阶段独立化对比不要一上来就追求和论文完全一致。2.3 一个可运行的 PyTorch 模型实现下面给出一个复现 InDuDoNet 最核心结构的 Python 实现。子网络先用一个轻量残差编码器方便快速验证你要替换成 U-Net 或 transformer 块也只是改forward。import torch import torch.nn as nn import torch.nn.functional as F class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.conv(x) class SubNet(nn.Module): 残差编码器作为双域子网络 def __init__(self, in_ch1, base_ch32): super().__init__() self.encoder nn.Sequential( ConvBlock(in_ch, base_ch), ConvBlock(base_ch, base_ch * 2), ) self.decoder nn.Sequential( nn.ConvTranspose2d(base_ch * 2, base_ch, kernel_size2, stride2), nn.ReLU(inplaceTrue), nn.Conv2d(base_ch, in_ch, 1), ) def forward(self, x): skip F.max_pool2d(self.encoder[0](x), 2) enc self.encoder[1](skip) dec self.decoder(enc) # 用亚像素卷积对齐尺寸后加残差 if dec.shape[-2:] ! x.shape[-2:]: dec F.interpolate(dec, sizex.shape[-2:], modebilinear, align_cornersFalse) return dec x代码逻辑每个子网络输出残差而不是直接输出重建结果这样网络学习的是“改动量”训练的收敛性比直接学全图好很多。SubNet里对最终输出做了插值对齐是为了防止转置卷积造成的尺寸不一致在双域循环里累积错误。class LearnableProjector(nn.Module): 可学习投影层用于 sinogram 与图像域之间转换 严格复现时可替换为 torch-radon 的 Radon / IRadon 层 def __init__(self, in_ch1, out_ch1): super().__init__() self.proj nn.Conv2d(in_ch, out_ch, kernel_size1, biasFalse) self.inv nn.Conv2d(out_ch, in_ch, kernel_size1, biasFalse) def forward(self, sino): return self.proj(sino) def inverse(self, img): return self.inv(img)LearnableProjector是一个妥协方案真正的 InDuDoNet 需要物理意义的 Radon/IRadon 来保证投影一致性。如果你手头没有现成的投影算子先用 1×1 卷积把两个域的数据形状对齐能帮你验证后续训练管线是否畅通但最终复现论文指标前一定要换成真实的投影层否则数据一致性会变成空话。class InDuDoNet(nn.Module): def __init__(self, T3, base_ch32, lamb0.2): super().__init__() self.T T self.lamb lamb self.sino_net SubNet(in_ch1, base_chbase_ch) self.img_net SubNet(in_ch1, base_chbase_ch) self.projector LearnableProjector() def forward(self, sino, img, sino_gt, apply_dcTrue): for _ in range(self.T): sino sino self.sino_net(sino) img self.projector(sino) img img self.img_net(img) if apply_dc and sino_gt is not None: # 数据一致性用前向投影残差修正图像域 sino_pred self.projector.inverse(img) residual sino_gt - sino_pred img img self.lamb * self.projector(residual) return img, sino参数说明T是展开阶段数论文里一般取 35越多越慢显存占用线性上涨lamb是数据一致性的步长0.10.3 之间比较安全太大会让网络只顾着拟合测量值导致图像变成噪声放大的投影apply_dc建议训练时打开测试时先开着看效果如果明显掉点再关闭做消融。这个实现里两个子网络在每个阶段共享参数想要阶段独立就把它们放进nn.ModuleList每个阶段一个。3. 训练一条 InDuDoNet 的完整流水线数据准备、损失函数与评价指标3.1 训练数据怎么准备InDuDoNet 的训练对是“退化 sinogram”和“干净图像”。如果你拿公开的低剂量 CT 数据通常拿到的是已经重建好的图像并不直接带 sinogram。常见做法是用 Radon 变换把干净图像正投影成 sinogram再模拟稀疏采样或加噪。这个步骤要放在数据预处理里不能放到__getitem__里慢速跑。import numpy as np import torch from torch.utils.data import Dataset from skimage.transform import radon, iradon class CTDataset(Dataset): def __init__(self, img_paths, noisy_level1000, sparse_angles180): self.paths img_paths self.noisy_level noisy_level self.sparse_angles sparse_angles def __len__(self): return len(self.paths) def __getitem__(self, idx): img np.load(self.paths[idx]).astype(np.float32) img img / img.max() # 模拟稀疏角度投影 sino radon(img, thetanp.linspace(0, 180, self.sparse_angles, endpointFalse)) # 加泊松噪声模拟低剂量 sino np.random.poisson(sino * self.noisy_level).astype(np.float32) / self.noisy_level # 反投影得到稀疏重建结果作为图像域输入 img_in iradon(sino, thetanp.linspace(0, 180, self.sparse_angles, endpointFalse)) return (torch.from_numpy(sino).unsqueeze(0), torch.from_numpy(img_in).unsqueeze(0), torch.from_numpy(img).unsqueeze(0))这里radon和iradon是 scikit-image 的实现角度数少时计算很快但可微性为零只能用于准备数据。注意三点sinogram 的通道维是 1sparse_angles 决定了角度采样密度噪声强度用noisy_level控制值越大噪声越低图像输入不是原图而是 iradon 出来的稀疏重建图这才能模拟 InDuDoNet 想要解决的退化。另一个值得注意的点是 CT 值的动态范围。很多 DICOM 导出数据的 HU 值在 -1000 到 3000直接丢给网络训练会不稳定。我一般会把训练数据先裁剪到 [0, 1]保留软组织对比度即可。否则 BatchNorm 的均值和方差会被极端值拉偏导致第一阶段 sinogram 修正完全失灵。3.2 训练配置学习率、batch size、epoch 与显存红线一个可直接跑的小配置如下展开阶段 T3base_ch32batch size 8Adam 初始学习率1e-4训练 100 个 epoch。显存不够优先减 batch size而不是减 T因为 T 减半后双域循环的语义就变了可能直接跑出空白图像。下面是一段训练循环骨架已经去掉模型初始化和 dataloader只保留核心逻辑。import torch.optim as optim from torch.cuda.amp import GradScaler, autocast model InDuDoNet(T3, base_ch32, lamb0.2).cuda() opt optim.Adam(model.parameters(), lr1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(opt, T_max100) scaler GradScaler() criterion torch.nn.L1Loss() for epoch in range(100): model.train() for sino, img_in, img_gt in train_loader: sino, img_in, img_gt sino.cuda(), img_in.cuda(), img_gt.cuda() opt.zero_grad() with autocast(): img_out, _ model(sino, img_in, sino_gtsino) loss criterion(img_out, img_gt) scaler.scale(loss).backward() scaler.step(opt) scaler.update() scheduler.step() # 每个 epoch 后在验证集上算一次 PSNR这个循环里有两个容易忽略的设置sino_gt用的是原始无噪 sinogram而监督信号img_gt是干净图像。也就是说数据一致性层强制的 sinogram 一致性是辅助约束最终评价指标仍然是重建图像质量。损失函数只用 L1 时收敛会比 MSE 慢一点但边缘更锐利。混合精度训练对显存帮助很大但要注意torch.cuda.amp在高版本 PyTorch 里会被自动混合精度替代如果你用的是 2.x 版本请把autocast改成torch.autocast(device_typecuda, dtypetorch.float16)。当 T 大于 4 时建议把梯度检查点打开否则 12 GB 显存大概率撑不过两轮迭代。3.3 损失函数组合L1、SSIM 与数据一致性的权重怎么调InDuDoNet 的损失不再是一个简单 L1。常见做法是图像域 L1 SSIM 数据一致性项。但怎么加权需要看你手里的数据如果重建目标里软组织对比度重要SSIM 权重要抬高如果只看 PSNR 排名L1 权重可以高一些。给一个冒烟测试的初始配置def l1_ssim_loss(img_out, img_gt, ssim_module): l1 F.l1_loss(img_out, img_gt) ssim_val ssim_module(img_out, img_gt) return 0.7 * l1 0.3 * (1 - ssim_val)我见过不少复现失败的人把 SSIM 的权重设到 0.5 以上结果图像边缘锐利但 PSNR 上不去换取的是肉眼观感更好但论文里的表格分数往往只报 PSNR。所以我的建议是先用纯 L1 跑通管线再逐步加 SSIM每调一次权重都要重新跑一次完整验证集不要凭印象拍脑袋。数据一致性项的理想做法是在模型 forward 内部完成不需要额外损失。但如果你的LearnableProjector和真实 Radon 物理模型差距太大数据一致性项等于让网络去拟合一个错误投影这时可以把它从 forward 里摘掉单独在外面用一个真实 Radon 算子计算残差损失。这样至少保证测试时数据一致性仍成立。4. 复现中绕不开的避坑清单从训练崩到测试翻车的 5 个真实案例4.1 现象模型不收敛loss 一直在高位震荡原因多半不是网络结构而是输入数据的数值范围没有对齐。sinogram 的像素值可以因为角度积分变得很大达到几百甚至几千图像域的干净图又是 01。两个域的数值差一个量级梯度反向传播时 sinogram 支路会瞬间爆炸。解决方法是把 sinogram 和图像都做 Min-Max 归一化并在 Dataset 里固定统计量而不是按单样本求 min/max——单样本归一化会让每个 batch 的数值尺度都不一样。我一般在预处理阶段先跑一遍全部样本存下来一个全局的 min 和 max运行时直接套用。这也方便后面推理时保持同一套归一化参数否则训练时网络学习的输入分布和测试输入不一致模型直接失效。4.2 现象显存溢出OOM展开网络最吃显存的地方不是本身参数量而是中间产生的多阶段特征图。每个阶段都要保留 sinogram、图像域特征、投影层结果T 等于 5 时一轮 forward 可能吃掉一块 24 GB 卡的全部显存。常见的处理顺序是先减 batch size再开混合精度然后是梯度检查点最后才考虑减少 T 或压低 base_ch。还有一个容易被忽视的点验证阶段也要走完整模型 forward并且 PyTorch 在torch.no_grad()下依然会构建 Graph 之外的中间内存若验证集 batch 过大一样会 OOM。把验证 batch size 设成训练的一半通常能解决问题。真到了 T 必须大、显存不够的时候用torch.utils.checkpoint包住每个子网络的 forward 是最值得的一笔改造from torch.utils.checkpoint import checkpoint sino checkpoint(self.sino_net, sino, use_reentrantFalse)注意 checkpoint 会牺牲 30% 左右的前向速度适合离线训练不适合线上推理。4.3 现象测试阶段图像出现棋盘格伪影棋盘格几乎可以确定是转置卷积造成的。nn.ConvTranspose2d在高频区域会有不均匀重叠尤其在双域交替的任务里sinogram 和图像域的棋盘格会互相放大。解决方案简单粗暴把上采样层换成nn.Upsample Conv2d效果立刻干净很多。代码如下self.decoder nn.Sequential( nn.Upsample(scale_factor2, modebilinear, align_cornersFalse), nn.Conv2d(in_ch, out_ch, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, in_ch, kernel_size1), )这个改动不会带来 PSNR 大幅下降却能消除主观评价里最显眼的伪影。如果你的 InDuDoNet 里还接了一个真实 Radon 反投影层棋盘格可能来自角度采样不均匀这时候要先画单张 sinogram 插值结果判断是网络还是重建层的问题。4.4 现象数据一致性层没有起到任何作用拼接了img img self.lamb * self.projector(residual)之后loss 确实下降了但去掉这一行测试指标几乎没有变化。这说明数据一致性层可能学成了一个“伪恒等变换”当 projector 是 1×1 卷积且 lamb 太小时残差信号经过两次卷积后信息丢失严重实际上只是给图像加了一层极小的噪声。解决思路是把 lamb 适当调大并且在数据一致性层后面强制让输出流经过一个可学习的门控gate sigmoid(conv(img))让网络自己决定每个空间位置接受多少一致性信息。另一种更符合论文精神的做法是把projector替换成基于 FFT 或 Radon 的物理可微层输入一张 sinogram 和一张图像能直接算出真实残差而不是让另一个卷积去猜。4.5 现象复现指标比论文低一大截论文里的 PSNR 通常是在特定测试集分布下获得的直接用公开低剂量 CT 测试集但忘记做角度范围对齐、像素裁剪和后处理都会导致指标差异。最容易被忽略的是评估时的边缘裁剪重建图像外围会有一圈 padding 伪影算 PSNR 时全图计算会把分数拉低 0.51 dB。一般做法是裁剪中心区域 80% 面积再算。另一个高频问题是模型输出被归一化到 [0,1]但论文报告的 PSNR 用的是原始 HU 值域。这种情况下你算出来的分数天然就低并不代表复现失败。先把测试图像统一回原始数值范围再复算指标才能得到公平对比。我每次复现都会把训练集、验证集、测试集的预处理管线完全独立避免训练时顺手做过裁剪而测试时忘了做。5. 验证模型复现是否成功特征图、中间结果与可视化的关键做法复现 InDuDoNet 不能只看训练 loss。展开类网络最大的问题是“看起来在跑实际上一层层在自我抵消”。我用一个固定的可视化清单来检查每个阶段输出的 sinogram、图像域中间结果、数据一致性残差、以及最终重建图的误差热图。先用一段可视化代码把中间结果存下来。这里用到register_forward_hook比改模型 forward 塞 return 列表更干净且不会影响训练代码结构。features {} def hook_fn(name): def fn(module, input, output): features[name] output.detach().cpu() return fn model.sino_net.register_forward_hook(hook_fn(sino_net)) model.img_net.register_forward_hook(hook_fn(img_net)) with torch.no_grad(): img_out, _ model(sino.cuda(), img_in.cuda(), sino_gtsino.cuda()) sino_mid features[sino_net][-1] # 取最后一个阶段的输出画图时有个高频小坑如果你想把不同角度、不同切片数的 sinogram 画在同一张 1D 曲线图里横坐标全是角度序号标签一多就会挤成黑块也就是常碰到的python画图横坐标太密集。解决办法很简单只标关键角度并且旋转标签import matplotlib.pyplot as plt plt.xticks(np.arange(0, sino_mid.shape[-2], step30), rotation45) plt.tight_layout() plt.savefig(sino_stage.png, dpi150)除了曲线图我更推荐直接输出二维 heatmap把每个阶段输出的 sinogram 和输入 sinogram 做差得到一个残差图。残差图上如果出现规律的竖条纹说明网络只是在记忆角度而不是学习投影域修正如果图像域误差图上边缘区域一片红说明训练数据量不够网络没有学会组织边缘。还有一个每次必做的验证把模型输出的 sinogram 再走一次真实 IRadon和干净图像对比。这个步骤能直接暴露LearnableProjector那种 1×1 卷积替代方案是否合格——如果替代层学出的 sinogram 是平滑的但 IRadon 出来的图像完全不是解剖结构那就说明投影关系根本没有建立网络只是在做一个“图像到图像的滤镜”和 InDuDoNet 的初衷背道而驰。关键指标方面除了 PSNR/SSIM我会额外记录一个东西每个阶段输出的图像残差的平均绝对值。正常训练下这个值应该逐渐递减如果两个相邻阶段残差在正负之间来回跳说明网络还没有稳定要继续训练或减少阶段数。6. 进阶把 InDuDoNet 移植到自己的数据集上的三个实用技巧如果你不是做 CT 而是做工业无损检测、地质勘探或雷达反演InDuDoNet 的双域思想同样适用。核心是把“投影域”换成你任务里的测量域把“图像域”换成反演结果。移植时需要做三件事自定义数据接口、替换物理投影算子、以及微调阶段数。数据接口上不需要照抄 CTDataset你只需要保证每个样本返回两个数组测量域数据和对应的反演真值。我把 Dataset 抽象成class CustomDataset(Dataset): def __init__(self, data): self.samples data # list of (domain_in, image_gt, measurement_gt) def __getitem__(self, idx): dom_in, img_gt, meas_gt self.samples[idx] return torch.tensor(dom_in).float().unsqueeze(0), \ torch.tensor(img_gt).float().unsqueeze(0), \ torch.tensor(meas_gt).float().unsqueeze(0)物理投影算子不要自己实现。PyTorch 生态里已有成熟的torch-radon直接调用它的 Radon/IRadon 层替换我前面写的LearnableProjector。算子替换后把lamb从 0.2 重新降到 0.05 再训练因为真实投影算子的残差量级和卷积学出来的完全不同。最后阶段数不是越多越好。我在自己的无损检测数据上试过 T2 到 T7工业 CT 的噪声是加性的、结构简单T2 就够用反而 T5 以后开始出现边缘过冲。以后你换数据时最好先跑一张图看不同 T 的输出差异再决定成本投入这是一条值得记住的经验。希望这篇笔记帮你把 InDuDoNet 的复现从黑匣子变成可以自己调节的白盒子。本文还有配套的精品资源点击获取
返回列表