多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于论文复现的InDuDoNet低剂量CT去噪Python实现源码

基于论文复现的InDuDoNet低剂量CT去噪Python实现源码 简介本资源为InDuDoNet模型的Python复现源码面向深度学习研究者与医学图像处理方向的开发者尤其适合需要复现CT图像分割算法、开展对比实验或二次开发的中高级学习者。项目围绕论文提出的InDuDoNet展开涵盖训练、推理、数据预处理与网络结构等完整环节并针对DeepLesion、大鼠股骨及CLINIC金属伪影等数据集提供了对应脚本与配置。压缩包共61个文件约918KB其中44个Python脚本承担模型与训练逻辑9个YAML文件管理实验配置另有txt日志说明、csv结果数据、mat矩阵文件及gitignore版本控制文件结构清晰、模块划分明确。目前已有384人学习下载。读者可据此获得可运行的复现基线理解投影与图像域联合处理的实现思路并借助现成配置快速迁移到自有数据减少从零搭建的成本。1. InDuDoNet 复现到底在复现什么从一张低剂量 CT 到可训练的 Python 工程低剂量 CT 去噪这个方向很多人第一次接触是因为 InDuDoNet 这篇工作把「模型驱动」和「数据驱动」两套路子缝到了一起。标题里说的「基于论文复现的 InDuDoNet 模型设计的 Python 实现源码」本质上是把论文里的展开迭代网络拆成能在本地跑起来的 Python 工程一份数据加载、一份网络定义、一份训练循环、一份推理脚本。它解决的不是「看懂公式」而是「公式怎么变成能 forward、能 backward、能出图的代码」。适合已经会 python 基础语法、装过 pytorch、想拿这个模型做低剂量 CT 或者图像重建实验的人。如果你还在 python 安装教程阶段建议先把环境跑通再回来否则后面调参全是玄学。2. 复现前的工程拆解InDuDoNet 的模块边界与依赖选型2.1 论文里的三个核心模块对应到哪几个文件InDuDoNet 的结构可以粗暴拆成三块数据一致性层、先验网络、迭代展开控制。数据一致性层负责把网络输出拉回观测域先验网络负责学图像先验迭代展开控制决定展开多少 stage、每 stage 共享不共享参数。复现时我一般把它们分成三个文件而不是全塞进一个 model.py原因是后面调 stage 数和共享策略时改一个文件就够不用满仓库找。模块建议文件名职责常见坑数据一致性层dc_layer.py在投影域或图像域做残差校正域选错loss 不降先验网络prior_net.pyUNet 或 ResNet 做去噪先验通道数对不上迭代展开indudonet.py串联 stage、控制参数共享stage 数写死选型上先验网络优先用轻量 UNet参数量控制在 1M 到 4M 之间。低剂量 CT 数据量通常不大先验网络太大直接过拟合验证集 PSNR 会在第 10 个 epoch 之后往下掉。优化器用 Adam初始学习率 1e-4配合 cosine 退火。这些不是论文里的唯一解但是复现时最稳的一组默认值。2.2 环境依赖与目录结构依赖不用多核心就四个torch、numpy、scikit-image、tqdm。python 版本建议 3.9 到 3.11太新的版本有些 torch 轮子还没跟上。vscode python 环境配置或者 pycharm 配置 python 环境都行关键是解释器选对别一个终端装 torch 另一个终端跑代码。# 建议用 conda 建独立环境避免和系统 python 打架 conda create -n indudonet python3.10 -y conda activate indudonet pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install numpy scikit-image tqdm这段命令的逻辑是先隔离环境再装 GPU 版 torch最后补图像处理和进度条依赖。参数说明cu118 对应 CUDA 11.8如果你机器是 CUDA 12.x把 index-url 换成 cu121。装完用python -c import torch; print(torch.cuda.is_available())验证返回 False 就先别往下走后面训练会慢到怀疑人生。目录结构建议这样indudonet/ data/ train/ val/ models/ dc_layer.py prior_net.py indudonet.py utils/ dataset.py metrics.py train.py infer.py configs.yaml把配置抽到 yaml 里stage 数、学习率、batch size 都从配置读。这样你复现不同实验时只改配置不动代码省掉大量后悔药。3. 数据管线与投影域处理复现能不能出结果的第一道关3.1 低剂量 CT 数据的读取与归一化低剂量 CT 复现翻车八成翻在数据上。原始 CT 值范围是 -1024 到 3000 左右直接喂网络梯度会炸。常见做法是先截断到 [-1000, 1000]再线性映射到 [0, 1]。这一步必须在训练和推理里保持一致否则推理图会整体偏亮或偏暗。import numpy as np def normalize_ct(img, win_min-1000, win_max1000): # 截断到窗宽窗位范围避免极端值主导梯度 img np.clip(img, win_min, win_max) # 线性映射到 [0,1]训练推理必须用同一组参数 img (img - win_min) / (win_max - win_min) return img.astype(np.float32)逻辑说明clip 负责去掉金属伪影和骨边界带来的极端值线性映射负责把动态范围压到网络友好的区间。参数说明win_min 和 win_max 不是随便定的要和你的数据集实际分布对齐可以先用np.percentile(img, [0.5, 99.5])看一眼再定。归一化参数一定要存下来推理时加载同一组不然训练和推理两个分布PSNR 直接掉 3dB 以上。3.2 投影域与图像域的取舍InDuDoNet 的数据一致性层可以放在投影域也可以放在图像域。投影域更贴近物理成像过程但需要你手里有 sinogram 或者能自己做 Radon 变换图像域实现简单但物理约束弱一些。复现时如果数据集只给了图像就老老实实做图像域别硬凑投影域否则要自己写 Radon 和 FBP误差来源一下多出好几个。from skimage.transform import radon, iradon def image_to_sino(img, theta): # 图像域转投影域theta 是角度采样 sino radon(img, thetatheta, circleTrue) return sino.astype(np.float32) def sino_to_image(sino, theta): # 投影域转回图像域用于数据一致性后重建 img iradon(sino, thetatheta, circleTrue, filter_nameramp) return img.astype(np.float32)逻辑说明radon 做前向投影iradon 做 FBP 重建。参数说明theta 建议 180 个角度起步太少会有明显条状伪影filter_name 用 ramp 是 CT 重建的常规选择。注意 iradon 输出范围可能不在 [0,1]重建后要再归一化一次。3.3 Dataset 与 DataLoader 的写法import torch from torch.utils.data import Dataset, DataLoader import numpy as np class LdctDataset(Dataset): def __init__(self, file_list, win_min-1000, win_max1000): self.files file_list self.win_min win_min self.win_max win_max def __len__(self): return len(self.files) def __getitem__(self, idx): # 假设数据是 npy形状 [H, W] ldct np.load(self.files[idx][ldct]) ndct np.load(self.files[idx][ndct]) ldct normalize_ct(ldct, self.win_min, self.win_max) ndct normalize_ct(ndct, self.win_min, self.win_max) # 增加通道维变成 [1, H, W] return torch.from_numpy(ldct[None]), torch.from_numpy(ndct[None]) loader DataLoader(LdctDataset(train_files), batch_size8, shuffleTrue, num_workers4)逻辑说明Dataset 负责单样本读取和归一化DataLoader 负责批处理和并行加载。参数说明batch_size 从 8 起步显存够可以加到 16num_workers 设成 CPU 核数的 1/2 左右设太大反而因为进程切换变慢。如果训练时 loss 一直是 nan先检查这里有没有把归一化漏掉。4. 网络实现把 InDuDoNet 的迭代展开写成可训练代码4.1 数据一致性层的实现数据一致性层的作用是网络预测出图像后把它和观测数据对齐。图像域版本可以简单写成加权残差。import torch import torch.nn as nn class DataConsistency(nn.Module): def __init__(self, lam0.1): super().__init__() # lam 控制数据一致性强度可学习也可以固定 self.lam nn.Parameter(torch.tensor(lam)) def forward(self, x, y): # x 是网络输出y 是低剂量观测 # 残差校正把输出往观测方向拉 return x - self.lam * (x - y)逻辑说明forward 里做的是最简形式的残差校正x 减掉 lam 倍的 (x-y)。参数说明lam 初始化 0.1设成可学习参数让网络自己调如果训练不稳定先把 lam 固定成 0.1 跑通再放开。注意 lam 不能太大超过 0.5 时输出会过度依赖观测先验网络学不到东西。4.2 先验网络的轻量 UNetclass ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.block(x) class PriorNet(nn.Module): def __init__(self, base_ch32): super().__init__() self.enc1 ConvBlock(1, base_ch) self.enc2 ConvBlock(base_ch, base_ch * 2) self.pool nn.MaxPool2d(2) self.up nn.Upsample(scale_factor2, modebilinear, align_cornersFalse) self.dec1 ConvBlock(base_ch * 2, base_ch) self.out nn.Conv2d(base_ch, 1, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) d1 self.dec1(torch.cat([self.up(e2), e1], dim1)) return self.out(d1)逻辑说明两层编码、两层解码中间用 MaxPool 下采样、Upsample 上采样skip connection 用 cat 拼回去。参数说明base_ch 控制参数量32 对应约 0.5M 参数适合小数据集数据量大可以加到 64。注意 up 的 align_corners 设 False和多数 pytorch 版本默认行为一致避免尺寸对不齐报错。4.3 迭代展开与 stage 参数共享class InDuDoNet(nn.Module): def __init__(self, stages5, share_weightsTrue): super().__init__() self.stages stages self.share share_weights self.dc DataConsistency() if share_weights: # 所有 stage 共享同一个先验网络 self.prior PriorNet() else: # 每个 stage 独立先验网络参数量翻 stages 倍 self.prior nn.ModuleList([PriorNet() for _ in range(stages)]) def forward(self, y): x y for i in range(self.stages): prior self.prior if self.share else self.prior[i] x prior(x) x self.dc(x, y) return x逻辑说明forward 里循环 stages 次每次先过先验网络再过数据一致性层。参数说明stages 从 5 起步论文里常用 5 到 8share_weights 设 True 时参数量小、训练稳设 False 时表达能力强但容易过拟合。复现建议先 True 跑通再试 False 对比。4.4 训练循环与损失函数import torch.nn.functional as F def train_one_epoch(model, loader, optimizer, device): model.train() total_loss 0 for ldct, ndct in loader: ldct, ndct ldct.to(device), ndct.to(device) pred model(ldct) # L1 对 CT 去噪比 L2 更稳边缘保留更好 loss F.l1_loss(pred, ndct) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader)逻辑说明标准训练循环前向、算 loss、反向、更新。参数说明loss 用 L1 而不是 MSE是因为 CT 去噪里 L1 对异常值更鲁棒边缘不容易糊。如果发现训练 loss 降但验证 PSNR 不涨先查数据对齐再查归一化参数是否一致。5. 复现避坑五个让 PSNR 卡住不动的真实原因5.1 训练 loss 正常但验证 PSNR 一直 20dB 出头现象训练集 loss 稳定下降验证集 PSNR 卡在 20 到 22dB 不动。原因训练和验证的归一化参数不一致或者验证集读取时漏了 normalize。解决把归一化参数写进 config训练和推理都从同一份 config 读验证前打印一张图的 min/max 确认范围在 [0,1]。5.2 输出图像整体偏灰、对比度低现象推理出来的图能看出结构但整体发灰像蒙了一层雾。原因数据一致性层的 lam 太大网络输出被观测数据拉得太狠先验没起作用。解决把 lam 从可学习改成固定 0.1 先跑或者给 lam 加一个上限约束训练时打印 lam 的值看它有没有跑飞。5.3 stage 数加到 8 以后显存爆掉现象stages 设 5 能跑设 8 直接 CUDA out of memory。原因如果 share_weights 设 False每个 stage 一个先验网络参数量和中间激活都翻倍。解决要么把 share_weights 改回 True要么减小 batch size要么用 gradient checkpointing。我一般先降 batch size 到 4再考虑 checkpointing。5.4 推理速度慢到没法用现象单张图推理要好几秒。原因stages 太多、先验网络太大或者没开 no_grad。解决推理时用with torch.no_grad():包住能省掉大量显存和计算stages 超过 8 时考虑蒸馏或者减少 stage 数实际部署里 5 个 stage 通常够用。5.5 换数据集后效果直接崩现象在自己数据上 PSNR 只有 18dB。原因新数据集的 CT 值分布和归一化窗口不匹配或者噪声水平差异大。解决先统计新数据集的 percentile重新定 win_min 和 win_max如果噪声水平差异大在训练时加噪声增强把不同噪声水平的样本混在一起训。6. 进阶技巧用可学习 stage 权重和混合损失把 PSNR 再抬 1dB跑通基础版本之后想再往上抬我一般从两个地方下手stage 权重和损失函数。基础版本里每个 stage 的输出权重是一样的但实际迭代展开中后面的 stage 应该贡献更大。可以给每个 stage 加一个可学习的标量权重最后加权求和。class InDuDoNetV2(nn.Module): def __init__(self, stages5): super().__init__() self.stages stages self.dc DataConsistency() self.prior PriorNet() # 每个 stage 一个可学习权重初始化为 1/stages self.stage_w nn.Parameter(torch.ones(stages) / stages) def forward(self, y): x y outs [] for i in range(self.stages): x self.prior(x) x self.dc(x, y) outs.append(x) # 加权融合所有 stage 输出 w torch.softmax(self.stage_w, dim0) return sum(wi * oi for wi, oi in zip(w, outs))逻辑说明把每个 stage 的输出存下来用 softmax 归一化的可学习权重加权求和。参数说明stage_w 初始化为均匀分布训练中会自动调整softmax 保证权重和为 1避免输出幅度失控。这个改动通常能带来 0.3 到 0.8dB 的提升代价是显存多存几份中间输出。损失函数方面纯 L1 已经不错但可以加一个梯度域损失让边缘更锐。def gradient_loss(pred, target): # 用 Sobel 算子提取梯度约束边缘 sobel_x torch.tensor([[-1,0,1],[-2,0,2],[-1,0,1]], dtypetorch.float32, devicepred.device).view(1,1,3,3) sobel_y sobel_x.transpose(2, 3) gx_p F.conv2d(pred, sobel_x, padding1) gy_p F.conv2d(pred, sobel_y, padding1) gx_t F.conv2d(target, sobel_x, padding1) gy_t F.conv2d(target, sobel_y, padding1) return F.l1_loss(gx_p, gx_t) F.l1_loss(gy_p, gy_t) # 总损失 L1 0.1 * 梯度损失 total F.l1_loss(pred, ndct) 0.1 * gradient_loss(pred, ndct)逻辑说明Sobel 卷积提取 x 和 y 方向梯度在梯度域算 L1。参数说明梯度损失权重从 0.1 起步太大图像会显得生硬太小没效果。验证时除了 PSNR也看一眼 SSIM梯度损失对 SSIM 的提升通常比 PSNR 明显。最后说个习惯每次改完网络结构或者损失先拿 10 个 batch 过拟合一遍确认 loss 能降到接近 0。如果连 10 个 batch 都过拟合不了说明代码有 bug别急着跑全量训练。这个习惯帮我省掉过很多次通宵等结果的时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表