拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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 python=3.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_max=1000): # 截断到窗宽窗位范围,避免极端值主导梯度 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, theta=theta, circle=True) return sino.astype(np.float32) def sino_to_image(sino, theta): # 投影域转回图像域,用于数据一致性后重建 img = iradon(sino, theta=theta, circle=True, filter_name='ramp') 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_max=1000): 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_size=8, shuffle=True, num_workers=4)

逻辑说明:Dataset 负责单样本读取和归一化,DataLoader 负责批处理和并行加载。参数说明:batch_size 从 8 起步,显存够可以加到 16;num_workers 设成 CPU 核数的 1/2 左右,设太大反而因为进程切换变慢。如果训练时 loss 一直是 nan,先检查这里有没有把归一化漏掉。

4. 网络实现:把 InDuDoNet 的迭代展开写成可训练代码

4.1 数据一致性层的实现

数据一致性层的作用是:网络预测出图像后,把它和观测数据对齐。图像域版本可以简单写成加权残差。

import torch import torch.nn as nn class DataConsistency(nn.Module): def __init__(self, lam=0.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 先验网络的轻量 UNet

class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.ReLU(inplace=True), ) def forward(self, x): return self.block(x) class PriorNet(nn.Module): def __init__(self, base_ch=32): 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_factor=2, mode='bilinear', align_corners=False) 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], dim=1)) 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, stages=5, share_weights=True): 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 到 8;share_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, stages=5): 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, dim=0) 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]], dtype=torch.float32, device=pred.device).view(1,1,3,3) sobel_y = sobel_x.transpose(2, 3) gx_p = F.conv2d(pred, sobel_x, padding=1) gy_p = F.conv2d(pred, sobel_y, padding=1) gx_t = F.conv2d(target, sobel_x, padding=1) gy_t = F.conv2d(target, sobel_y, padding=1) 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,别急着跑全量训练。这个习惯帮我省掉过很多次通宵等结果的时间。希望帮到你。

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

返回列表