前面几篇我们实测过:同一个体模,30 个角度的 FBP 只有 22.14 dB,满屏条纹;换成 SART 加 TV 能到 36.35 dB,但要迭代 200 次,还要手调正则权重。
2016 年底,EPFL 的 Jin、McCann、Froustey 和 Unser 给出了另一条路:先用 FBP 粗重建,再让一个 U-Net 把条纹去掉。这就是 FBPConvNet。方法简单到一句话就能说完,却成了后来深度学习 CT 重建论文里最常用的对比基线之一。
这篇按"它解决什么问题 → 为什么这样设计 → 代码里怎么实现 → 实验说明了什么 → 读的时候要注意什么"的顺序拆开讲。文中的代码全部摘自作者开源仓库,数字全部来自论文原文。
- 论文:K. H. Jin, M. T. McCann, E. Froustey, M. Unser. Deep Convolutional Neural Network for Inverse Problems in Imaging. IEEE Transactions on Image Processing, 2017, 26(9): 4509--4522(arXiv:1611.03679)
- 代码:https://github.com/panakino/FBPConvNet(MATLAB + MatConvNet)
一、要解决的问题:稀疏视角
论文的实验场景是稀疏视角 CT:投影角度从上千个减到几十、一百多个。角度少了,FBP 会产生大量条纹伪影;正则化迭代方法(比如 TV)能压住条纹,但有两个现实问题:
- 慢 。每一步迭代都要计算一次正投影和反投影(或 H ∗ H H^*H H∗H)。论文里的 TV 方法重建一张 512 × 512 512 \times 512 512×512 的图约需 7 分钟,这还是在正则参数已经选好之后。
- 难调。正则权重要手工挑,换一批数据可能就要重调。
FBPConvNet 想要的是:重建质量接近甚至超过迭代方法,速度接近 FBP。
二、核心观察:迭代算法本来就长得像 CNN
这篇论文最有意思的部分不是网络,而是它给"为什么用 CNN"找的理由。
第一步:法算子是卷积。 设前向算子为 H H H,伴随为 H ∗ H^* H∗。论文证明了一类算子(满足一定分解条件)的法算子 H ∗ H H^*H H∗H 是卷积。CT 的 Radon 变换就属于这一类:由中心切片定理, R ∗ R R^*R R∗R 是一个卷积,频域响应为
h ^ ( ω ) = 1 ∥ ω ∥ \hat{h}(\boldsymbol{\omega}) = \frac{1}{\lVert \boldsymbol{\omega} \rVert} h^(ω)=∥ω∥1
这和(一)里讲的一致:直接反投影会让图像变模糊,模糊核在频域是 1 / ∥ ω ∥ 1/\lVert \boldsymbol{\omega} \rVert 1/∥ω∥,所以 FBP 要先乘一个 ramp 滤波器把它补回来。
第二步:迭代一步 = 卷积 + 偏置 + 逐点非线性。 以稀疏正则的经典问题为例:
arg min a ∥ y − H W a ∥ 2 2 + λ ∥ a ∥ 1 \arg\min_{\mathbf{a}} \lVert \mathbf{y} - \mathbf{H}\mathbf{W}\mathbf{a} \rVert_2^2 + \lambda \lVert \mathbf{a} \rVert_1 argamin∥y−HWa∥22+λ∥a∥1
W \mathbf{W} W 是小波之类的稀疏变换, x = W a \mathbf{x} = \mathbf{W}\mathbf{a} x=Wa 是图像。用 ISTA 求解,每一步是:
a k + 1 = S λ / L ( 1 L W ∗ H ∗ y + ( I − 1 L W ∗ H ∗ H W ) a k ) \mathbf{a}^{k+1} = S_{\lambda/L}\left( \frac{1}{L}\mathbf{W}^*\mathbf{H}^*\mathbf{y} + \left(\mathbf{I} - \frac{1}{L}\mathbf{W}^*\mathbf{H}^*\mathbf{H}\mathbf{W}\right)\mathbf{a}^k \right) ak+1=Sλ/L(L1W∗H∗y+(I−L1W∗H∗HW)ak)
S θ S_\theta Sθ 是软阈值, L L L 是 Lipschitz 常数。当 H ∗ H \mathbf{H}^*\mathbf{H} H∗H 和 W \mathbf{W} W 都是卷积时,括号里就是"对 a k \mathbf{a}^k ak 做一次滤波,再加一个偏置",外面套一个逐点非线性。把迭代展开,就是一层层"卷积 + 偏置 + 非线性",形式上和 CNN 一样。
第三步:所以先直接求逆,再交给 CNN。 对这类问题,总有一个快速的直接求逆(CT 里就是 FBP)。它包含了物理模型,但在问题病态时会带来伪影。剩下的去伪影,交给 CNN。
需要说明的是,作者明确表示:迭代算法只是动机,他们并不打算让网络去模仿某个展开的迭代算法,而是直接选用当时效果最好的 CNN 结构之一 U-Net。把迭代算法真正展开成网络的工作,是下一篇要讲的 Learned Primal-Dual。
三、方法:FBP + 带残差的 U-Net
1. 为什么先做 FBP
网络也可以直接从正弦图回归到图像,但那样它得自己学会极坐标到笛卡尔坐标的变换。先做 FBP,这一步就省掉了:物理模型交给 FBP,网络只需要在图像域里把"有条纹的图"映射成"没条纹的图",FBP 结果同时也是网络的一个好起点。
2. 为什么选 U-Net
论文给了三个理由:
- 多尺度分解 。U-Net 用最大池化逐级下采样,越往中间,等效感受野越大。CT 的 H ∗ H H^*H H∗H 及其逆对应的卷积核不是紧支撑的,固定小卷积核的网络可能无法有效地对它求逆。这一点也对应迭代方法里的多分辨率小波。
- 多通道。每层有多个特征图,对应 ISTA 里小波系数的多个子带,或 ADMM 里的多个分裂变量。
- 残差学习 。在网络输入和输出之间加一条跳连,网络实际学的是"输出 − 输入",也就是伪影本身。论文称,这比不加跳连的同一网络有明显提升。
另外两处小改动:卷积用零填充,保证尺寸不变;最后一层换成 1 × 1 1 \times 1 1×1 卷积,把 64 个通道压成 1 个输出(原版 U-Net 做分割,输出前景、背景两个通道)。
3. 代码里的网络结构
网络定义在作者仓库 cnn_unet_init.m 的 unet 函数里。下面是这个函数构建网络的全部语句,原样摘录,未做删改:
matlab
net.layers = {} ;
ch_length = opts.channel_in;
ch_length_out = opts.channel_out;
KerenlSize = 3;
zeroPad = floor(KerenlSize/2);
net = add_block(net, opts, '0', KerenlSize, KerenlSize, ch_length, 64, 1, zeroPad,1,1) ;
net = add_block(net, opts, '0', KerenlSize, KerenlSize, 64, 64, 1, zeroPad,1,1) ;
net = add_block(net, opts, '0', KerenlSize, KerenlSize, 64, 64, 1, zeroPad,1,1) ;
net = add_reg_toss(net, '0',1);
net = add_pool(net, opts,'0', 2, 0);
net = add_block(net, opts, '1_1', KerenlSize, KerenlSize, 64, 128, 1, zeroPad,1,1) ;
net = add_block(net, opts, '1_2', KerenlSize, KerenlSize, 128, 128, 1, zeroPad,1,1) ;
net = add_reg_toss(net, '1',2);
net = add_pool(net, opts,'1', 2, 0);
net = add_block(net, opts, '2_1', KerenlSize, KerenlSize, 128, 256, 1, zeroPad,1,1) ;
net = add_block(net, opts, '2_2', KerenlSize, KerenlSize, 256, 256, 1, zeroPad,1,1) ;
net = add_reg_toss(net,'2',3);
net = add_pool(net, opts,'2', 2, 0);
net = add_block(net, opts, '3_1', KerenlSize, KerenlSize, 256, 512, 1, zeroPad,1,1) ;
net = add_block(net, opts, '3_2', KerenlSize, KerenlSize, 512, 512, 1, zeroPad,1,1) ;
net = add_reg_toss(net,'3',4);
net = add_pool(net, opts,'3',2,0);
net = add_block(net, opts, '4_1', KerenlSize, KerenlSize, 512, 1024, 1, zeroPad,1,1) ;
net = add_block(net, opts, '4_2', KerenlSize, KerenlSize, 1024, 1024, 1, zeroPad,1,1) ;
net = add_block_convt(net, opts, '4_3', KerenlSize, KerenlSize, 1024, 512, 2, [0 1 0 1],1,1) ;
net = add_reg_concat(net, '5_0',4);
net = add_block(net, opts, '5_1', KerenlSize, KerenlSize, 1024, 512, 1, zeroPad,1,1) ;
net = add_block(net, opts, '5_2', KerenlSize, KerenlSize, 512, 512, 1, zeroPad,1,1) ;
net = add_block_convt(net, opts, '5_3', KerenlSize, KerenlSize, 512, 256, 2, [0 1 0 1],1,1) ;
net = add_reg_concat(net, '6_0',3);
net = add_block(net, opts, '6_1', KerenlSize, KerenlSize, 512, 256, 1, zeroPad,1,1) ;
net = add_block(net, opts, '6_2', KerenlSize, KerenlSize, 256, 256, 1, zeroPad,1,1) ;
net = add_block_convt(net, opts, '6_3', KerenlSize, KerenlSize, 256, 128, 2, [0 1 0 1],1,1) ;
net = add_reg_concat(net, '7_0',2);
net = add_block(net, opts, '7_1', KerenlSize, KerenlSize, 256, 128, 1, zeroPad,1,1) ;
net = add_block(net, opts, '7_2', KerenlSize, KerenlSize, 128, 128, 1, zeroPad,1,1) ;
net = add_block_convt(net, opts, '7_3', KerenlSize, KerenlSize, 128, 64, 2, [0 1 0 1],1,1) ;
net = add_reg_concat(net, '8_0',1);
net = add_block(net, opts, '8_1', KerenlSize, KerenlSize, 128, 64, 1, zeroPad,1,1) ;
net = add_block(net, opts, '8_2', KerenlSize, KerenlSize, 64, 64, 1, zeroPad,1,1) ;
net = add_block(net, opts, '8_3', 1, 1, 64, ch_length_out, 1, 0,0,0) ;
对照着读(各函数的含义以同一文件和 vl_simplenn_fbpconvnet.m 中的实现为准):
add_block依次加入卷积层、BN 层、ReLU 层,最后两个参数(batchOn、ReluOn)控制是否加 BN 和 ReLU;这里卷积核大小KerenlSize = 3,零填充 1,所以尺寸不变;- 编码器第一级有 3 个卷积块(1→64→64→64),之后每级 2 个卷积块,通道数依次为 128、256、512、1024;
add_pool(..., 2, 0)是步长 2 的 2 × 2 2 \times 2 2×2 最大池化,共 4 次下采样;add_block_convt是步长 2 的 3 × 3 3 \times 3 3×3 转置卷积(同样带 BN 和 ReLU),共 4 次上采样,每次通道数减半;add_reg_toss把当前特征图存进编号为 1~4 的寄存位,add_reg_concat在解码器对应层级把它取出来,按通道和当前特征图拼接,这就是 U-Net 的跳连;- 最后一层
'8_3'是 1 × 1 1 \times 1 1×1 卷积,64 通道压到ch_length_out(main.m中设为 1),最后两个参数是0,0,不加 BN 和 ReLU。
一个小差异:论文附录伪代码写的是 B ( R ( ⋅ ) ) B(R(\cdot)) B(R(⋅)),即先 ReLU 再 BN;而代码里 add_block 的层顺序是卷积 → BN → ReLU。复现时以代码为准。
4. 残差和损失在代码里是怎么做的
论文说"在输入和输出之间加跳连",但上面的网络定义里并没有这条跳连。它在数据和评估两头实现。
训练时,cnn_fbpconvnet.m 里取数据的函数 getSimpleNNBatch 最后两行把标签换成了"标签 − 输入"。按 main.m 的设置,输入 imdb.images.noisy 是稀疏视角 FBP(lab_d),标签 imdb.images.orig 是全视角 FBP(lab_n):
matlab
lowRes = images(:,:,1,:);
labels(:,:,1,:) = labels(:,:,1,:) - lowRes;
评估时,evaluation.m 再把输入加回去:
matlab
gt=lab_n(:,:,1,iter);
m=lab_d(:,:,1,iter);
if strcmp(cmode,'gpu')
res=vl_simplenn_fbpconvnet_eval(net,gpuArray((single(m))));
rec=gather(res(end-1).x)+m;
else
res=vl_simplenn_fbpconvnet_eval(net,((single(m))));
rec=(res(end-1).x)+m;
end
m 是稀疏视角 FBP,gt 是全视角 FBP。MatConvNet 里 res(i+1).x 是第 i 层的输出;网络最后一层是 euclideanloss 损失层,所以 res(end-1).x 是它前面那个 1 × 1 1 \times 1 1×1 卷积层的输出,也就是网络预测的残差。输出加上 m 才是最终重建,对应论文附录伪代码最后一行 X ∗ ← X d + ( w ⋆ O 1 + b ) \mathbf{X}^* \leftarrow \mathbf{X}_d + (\mathbf{w} \star \mathbf{O}_1 + \mathbf{b}) X∗←Xd+(w⋆O1+b)。
损失层的实现在 vl_euclideanloss.m:
matlab
if nargin == 2 || (nargin == 3 && isempty(dzdy))
Y = sqrt(sum(subsref((X - c) .^ 2, substruct('()', {':'}))));
elseif nargin == 3 && ~isempty(dzdy)
assert(numel(dzdy) == 1);
Y = dzdy * (X - c);
end
前向返回的是 ∥ X − c ∥ 2 \lVert X - c \rVert_2 ∥X−c∥2,反向传给上一层的梯度是 X − c X - c X−c。按反向传播的梯度看,实际优化的是平方误差 1 2 ∥ X − c ∥ 2 2 \frac{1}{2}\lVert X - c \rVert_2^2 21∥X−c∥22(对像素求和,不是求平均)。这里 X X X 是网络输出, c c c 是残差标签。
5. 训练设置:论文与代码对照
| 项目 | 论文 | 作者代码(main.m 等) |
|---|---|---|
| 训练 / 测试 | 475 / 25 张 | 共 500 张,最后 5%(25 张)为测试 |
| 输入尺寸 | 512 × 512 512 \times 512 512×512 整图 | patchSize = 512 |
| batch size | 1 | 1 |
| 动量 | 0.99 | 0.99 |
| 学习率 | 0.01 对数递减到 0.001 | logspace(-2, -3, 20),第 20 轮后保持 0.001 |
| 截断 | 梯度截断,阈值 10 − 2 10^{-2} 10−2 | gradMax = 1e-2,作用在每一步的参数更新量上 |
| 训练轮数 | 101 轮,约 15 小时(Titan Black) | numEpochs = 151,提供的预训练模型为 net-epoch-151 |
| 数据增强 | 水平、垂直翻转 | 各以 0.5 概率翻转 |
| 图像范围 | 缩放到 0~550 | --- |
两处值得注意:一是代码里的"梯度截断"实际是对 学习率 × 动量项 逐元素截断到 − 10 − 2 , 10 − 2 -10\^{-2}, 10\^{-2} −10−2,10−2,也就是限制每一步参数的改变量;二是仓库只提供了椭圆数据集 ×20 的数据,README 也说明 SNR 可能与论文略有不同。
四、实验
1. 数据
| 数据集 | 内容 | 全视角正弦图 | 稀疏采样 |
|---|---|---|---|
| 椭圆 | 500 张随机椭圆,解析投影 | 729 像素 × 1000 角度 | ×7(143 角度)、×20(50 角度) |
| 生物医学 | 500 张 Mayo 低剂量挑战赛的 CT 图像,用 radon 生成投影 |
729 像素 × 1000 角度 | ×7、×20 |
| 实验数据 | 瑞士光源 TOMCAT 线站的 377 张大鼠脑真实正弦图 | 1493 像素 × 721 角度 | ×5(145 角度)、×14(52 角度) |
生物医学数据的测试集来自与训练集不同的受试者;实验数据的测试集取最后 25 层,与训练集之间隔开 25 层。
参考图像是全视角 FBP,不是体模真值。 作者的理由是:真实数据永远拿不到真值,这样合成数据和真实数据可以用同一套流程处理。
2. 指标:不是 PSNR
论文用的是一种"回归 SNR":
SNR = max a , b ∈ R 20 log 10 ∥ x ∥ 2 ∥ x − a x ^ + b ∥ 2 \text{SNR} = \max_{a, b \in \mathbb{R}} 20 \log_{10} \frac{\lVert \mathbf{x} \rVert_2}{\lVert \mathbf{x} - a\hat{\mathbf{x}} + b \rVert_2} SNR=a,b∈Rmax20log10∥x−ax^+b∥2∥x∥2
先对重建结果做一个最优的线性变换 a x ^ + b a\hat{\mathbf{x}} + b ax^+b(最小二乘拟合),再算 SNR。代码 computeRegressedSNR.m 就是这样做的:
matlab
A = [sumI2, sumI; sumI, numel(oracle)];
b = [sumIP;sumP] ;
c = (A)\b ;
rec = c(1)*rec+c(2) ;
err = sum((oracle(:)-rec(:)).^2) ;
SNR = 10*log10(sum(oracle(:).^2)/err) ;
它和 PSNR 有两点不同:分子是参考图像的能量而不是峰值的平方;而且允许整体缩放和平移,所以对亮度、对比度的整体偏差不敏感。上一篇讲过,PSNR、SSIM 本身就依赖 data_range;这里的 SNR 更是另一套定义,不能和其他论文的 PSNR 直接比。
3. 主要结果(平均 SNR,dB)
| 数据集 | 采样 | FBP | TV | FBPConvNet |
|---|---|---|---|---|
| 椭圆 | 143 角度(×7) | 16.09 | 29.48 | 28.96 |
| 椭圆 | 50 角度(×20) | 8.44 | 27.69 | 23.84 |
| 生物医学 | 143 角度(×7) | 24.97 | 31.92 | 36.15 |
| 生物医学 | 50 角度(×20) | 13.52 | 25.2 | 28.83 |
| 实验数据 | 145 角度(×5) | 5.38 | 8.25 | 11.34 |
| 实验数据 | 52 角度(×14) | 3.29 | 7.25 | 8.85 |
(实验数据 52 角度一行,论文表 III 写作"51 views (x14)",正文数据准备部分写的是 52,这里按正文。)
结论要分开看:
- 椭圆数据上 TV 更好,×20 时领先近 4 dB。作者的解释是:椭圆体模是分片常数,正好是 TV 最擅长的情形。
- 生物医学和真实数据上 FBPConvNet 更好。作者认为增益来自对细节的保留:TV 会出现典型的"卡通化",FBPConvNet 能保留更多纹理。用他们的话说,迭代方法要显式指定正则项,而 FBPConvNet 从数据里学了一个正则项。
- 速度:FBP 约 200 ms,CNN 在 GPU 上约 200~300 ms,合计不到 1 秒;TV 约 7 分钟。
4. 消融:每个设计都有用吗
TIP 版本补了一组结构对比(生物医学数据,平均 SNR,dB):
| 采样 | 残差网络(无多尺度) | 原版 U-Net(无残差) | 在正弦图域补全 | FBPConvNet |
|---|---|---|---|---|
| 143 角度 | 34.18 | 28.65 | 34.21 | 35.28 |
| 50 角度 | 25.79 | 18.10 | 22.98 | 27.71 |
- 去掉残差,下降最多:50 角度时从 27.71 掉到 18.10;
- 去掉多尺度,也有 1~2 dB 的损失;
- 把网络放在正弦图域、FBP 之前做,不如放在图像域。
注意这组实验因为显存限制,把全视角正弦图减到了 721 个角度,所以数字和上一张表不能直接对比。表头里的"×5"和"×14"也是相对 721 个角度说的(721/143 ≈ 5,721/50 ≈ 14)。
5. 泛化与稳定性
- 换采样率会掉性能。用 143 角度训练的网络去处理 50 角度的输入,结果会残留大量条纹,要重新训练;论文在讨论部分把这列为主要局限。TIP 版本补充了实验:测试条件偏离训练条件时性能下降,但仍明显优于 FBP,用新数据再微调 10 轮(约 1.5 小时)能缓解。
- 对噪声有一定容忍度。训练数据不加噪声,测试时在正弦图上加高斯白噪声:正弦图 SNR 降到 50 dB 以下之前,重建性能基本不变,之后逐渐下降。
- 迁移到 MRI。作为概念验证,作者把同一网络用于 6 倍欠采样的膝关节 MRI(取模值处理复数),输入换成零填充逆傅里叶变换的结果,称为 BPConvNet。
五、读这篇论文时要注意的几点
FBPConvNet 的思路很清楚,但它的实验设置和今天的论文差别不小,引用或复现时要留意:
- 参考图像是全视角 FBP。 网络学的是"稀疏视角 FBP → 全视角 FBP",它的上限就是全视角 FBP,而全视角 FBP 自身也有离散化误差。今天很多论文用体模真值或正常剂量重建作参考,设置不同。
- TV 的参数是在测试集上调的。 TIP 版本写明,TV 的参数通过黄金分割搜索在测试集上取最优(arXiv v1 写的是训练集)。这对 TV 是有利的。所以 FBPConvNet 在两个更真实的数据集上胜出,结论更站得住;而比较两者差距时,也要把这一点考虑进去。
- "视角减少 20 倍 = 剂量减少 20 倍"有前提。 这要求每个角度的剂量不变。而论文没有提到在合成正弦图里加入噪声;真实场景下每个角度的光子数也会变少,噪声问题是另一回事(参见上一篇)。
- 指标是回归 SNR。 允许线性校正之后再算,不是 PSNR,也没有报 SSIM。
- 每个采样率单独训练一个网络。 这是后处理类方法的通病,后来的展开网络、扩散模型等方法,很大一部分动机就是把物理模型重新放回网络里,减轻这个问题。
六、它为什么重要
FBPConvNet 不是第一篇把 CNN 用在 CT 上的论文,作者在相关工作里也提到了同期的低剂量 CT 后处理工作,以及 Han 等人在其投稿后不久独立提出的稀疏视角多分辨率残差网络。它的影响力主要来自三点:
- 给了一个清楚的理论动机:法算子是卷积的逆问题,迭代求解的结构和 CNN 一致;
- 给了一个极简、好复现的方案:任何能做 FBP 的人都能接上一个 U-Net;
- 代码、预训练模型和部分数据开源,论文也获得了 IEEE 信号处理学会最佳论文奖。
所以后来的方法,无论是展开网络、双域网络还是扩散模型,经常把"FBP + U-Net"列为基线。它也划出了一条清晰的分界:FBPConvNet 只在网络之前用了一次物理模型;之后的很多工作,都在想办法让网络在每一步都用上物理模型。
这些方向的代表论文、开源代码和数据集,我整理在了一个开源列表里,可以按方向筛选、支持中文搜索:
参考文献
- Jin K H, McCann M T, Froustey E, Unser M. Deep convolutional neural network for inverse problems in imaging. IEEE Transactions on Image Processing, 2017, 26(9): 4509--4522.
- Ronneberger O, Fischer P, Brox T. U-Net: Convolutional networks for biomedical image segmentation. MICCAI, 2015: 234--241.
下一篇精读 Learned Primal-Dual:它不再"先 FBP 再去伪影",而是把原始--对偶迭代算法展开成网络,让正投影和反投影出现在网络的每一层里。
如果这篇对你有帮助,欢迎收藏,有问题可以在评论区讨论。