上一篇的 FBPConvNet 先做 FBP,再让 U-Net 去条纹。这条路简单好用,但有一个绕不开的上限:网络只看得到 FBP 图像,FBP 里已经丢掉的信息,后处理补不回来。
2017 年,KTH 的 Jonas Adler 和 Ozan Öktem 换了个思路:不先做 FBP,直接从原始投影数据出发,把一个经典的原始--对偶优化算法展开成 10 次迭代,每一次迭代里的近端算子换成小 CNN,正投影和反投影则原样留在网络里。这就是 Learned Primal-Dual(下面简称 LPD)。它和 FBPConvNet 一样,是深度学习 CT 重建论文里常被拿来对比的经典方法。
这篇按"它从哪个算法来 → 怎么改成网络 → 代码怎么写 → 实验说明了什么 → 读的时候要注意什么"的顺序讲。文中代码全部原样摘自作者开源仓库,数字全部来自论文原文。
- 论文:J. Adler, O. Öktem. Learned Primal-Dual Reconstruction. IEEE Transactions on Medical Imaging, 2018, 37(6): 1322--1332(arXiv:1707.06474)
- 代码:https://github.com/adler-j/learned_primal_dual(TensorFlow 1.x + ODL,算子用 ASTRA 的 GPU 后端)
一、出发点:PDHG(Chambolle--Pock)算法
(二)里讲过,带正则的重建可以写成
min f L ( T ( f ) , g ) + λ S ( f ) \min_{f} \; \mathcal{L}\big(T(f), g\big) + \lambda \mathcal{S}(f) fminL(T(f),g)+λS(f)
T T T 是前向算子(CT 里就是 Radon 变换 P P P), g g g 是测量数据。TV 这类正则不可导,常用近端(proximal)方法来解。论文选的是原始--对偶混合梯度法(PDHG),也就是 Chambolle--Pock 算法。它求解的是这样一类问题:
min f F ( K ( f ) ) + G ( f ) \min_{f} \; F\big(K(f)\big) + G(f) fminF(K(f))+G(f)
除了图像(原始变量) f f f,它还引入一个和数据同空间的对偶变量 h h h,两者交替更新。论文给出的(支持非线性 K K K 的)PDHG 每一步是:
h i + 1 = prox σ F ∗ ( h i + σ K ( f ˉ i ) ) f i + 1 = prox τ G ( f i − τ ∂ K ( f i ) ∗ ( h i + 1 ) ) f ˉ i + 1 = f i + 1 + γ ( f i + 1 − f i ) \begin{aligned} h_{i+1} &= \operatorname{prox}{\sigma F^*}\big(h_i + \sigma K(\bar f_i)\big) \\ f{i+1} &= \operatorname{prox}{\tau G}\big(f_i - \tau\, \\partial K(f_i)^*(h{i+1})\big) \\ \bar f_{i+1} &= f_{i+1} + \gamma\,(f_{i+1} - f_i) \end{aligned} hi+1fi+1fˉi+1=proxσF∗(hi+σK(fˉi))=proxτG(fi−τ∂K(fi)∗(hi+1))=fi+1+γ(fi+1−fi)
F ∗ F^* F∗ 是 F F F 的 Fenchel 共轭, ∂ K ( f ) ∗ \\partial K(f)^* ∂K(f)∗ 是 K K K 在 f f f 处导数的伴随。对线性的 Radon 变换,它就是反投影 P ∗ P^* P∗。以 TV 正则 CT 为例,论文取 K ( f ) = ( P f , ∇ f ) K(f) = (P f, \nabla f) K(f)=(Pf,∇f)、 F ( h ( 1 ) , h ( 2 ) ) = ∥ h ( 1 ) − g ∥ 2 2 + λ ∥ h ( 2 ) ∥ 1 F(h^{(1)}, h^{(2)}) = \lVert h^{(1)} - g \rVert_2^2 + \lambda \lVert h^{(2)} \rVert_1 F(h(1),h(2))=∥h(1)−g∥22+λ∥h(2)∥1、 G = 0 G = 0 G=0。
关键是结构:每一步 = 一次正投影 + 一个"数据空间里的近端算子" + 一次反投影 + 一个"图像空间里的近端算子"。
二、从 Learned PDHG 到 Learned Primal-Dual
1. 第一版:直接把近端算子换成网络(Learned PDHG)
前人已经发现,近端算子可以换成别的去噪器(比如 BM3D)。论文的第一步是:把两个 prox 换成可学习的网络 Γ θ d \Gamma_{\theta^d} Γθd(对偶)和 Λ θ p \Lambda_{\theta^p} Λθp(原始),步长 σ , τ \sigma, \tau σ,τ 和过松弛系数 γ \gamma γ 也一起学,迭代次数固定为 I I I。
结果并不好。作者原话的意思是:效果和传统方法相当,但没超过当时的深度学习方法。这一版在后面的表格里叫 Learned PDHG。
2. 四处修改
为了真正提升效果,作者做了四处修改:
- 给变量加"记忆" 。原始变量扩成 N primal N_{\text{primal}} Nprimal 个通道 f = f ( 1 ) , ... , f ( N primal ) \mathbf f = f\^{(1)}, \\dots, f\^{(N_{\\text{primal}})} f=f(1),...,f(Nprimal),对偶变量扩成 N dual N_{\text{dual}} Ndual 个通道,迭代之间可以传递更多信息;
- 不再硬编码 h i + σ K ( f ˉ i ) h_i + \sigma K(\bar f_i) hi+σK(fˉi),让网络自己学怎么把上一步的对偶变量和算子结果组合起来;
- 不再硬编码过松弛,让网络自己决定在哪个点上计算正投影;
- 每次迭代用不同的参数。参数量变多了,但重建质量明显提升。
得到的就是 Learned Primal-Dual:
h i = Γ θ i d ( h i − 1 , K ( f i − 1 ( 2 ) ) , g ) f i = Λ θ i p ( f i − 1 , ∂ K ( f i − 1 ( 1 ) ) ∗ ( h i ( 1 ) ) ) i = 1 , ... , I \begin{aligned} \mathbf h_i &= \Gamma_{\theta_i^d}\Big(\mathbf h_{i-1},\; K\big(f^{(2)}{i-1}\big),\; g\Big) \\ \mathbf f_i &= \Lambda{\theta_i^p}\Big(\mathbf f_{i-1},\; \big\\partial K\\big(f\^{(1)}_{i-1}\\big)\\big^*\big(h^{(1)}_i\big)\Big) \end{aligned} \qquad i = 1, \dots, I hifi=Γθid(hi−1,K(fi−1(2)),g)=Λθip(fi−1,∂K(fi−1(1))∗(hi(1)))i=1,...,I
最后输出 f I ( 1 ) f^{(1)}_I fI(1)。读这两行要抓住三个下标:
- 正投影作用在原始变量的第 2 个通道 f ( 2 ) f^{(2)} f(2) 上(这就是"网络自己决定在哪个点算正投影");
- 反投影作用在对偶变量的第 1 个通道 h ( 1 ) h^{(1)} h(1) 上;
- 输出取原始变量的第 1 个通道 f ( 1 ) f^{(1)} f(1)。
初值论文用全零。作者也试过用 FBP 做初值,训练快了一点,但最终结果没有变好,所以论文只报告全零初值的结果。这意味着 LPD 完全不依赖 FBP,这是它和 FBPConvNet 最本质的区别。
3. 它能退化回 PDHG
论文指出,取 N primal = 2 N_{\text{primal}} = 2 Nprimal=2、 N dual = 1 N_{\text{dual}} = 1 Ndual=1,再把 Γ \Gamma Γ、 Λ \Lambda Λ 取成特定的形式,LPD 就恰好是经典 PDHG;梯度下降也能写成它的特例。由于神经网络的万能逼近性质,作者认为在训练充分时,LPD 在同样的迭代次数下应该至少不比这些变分方法差。
三、代码:网络到底长什么样
下面的代码都摘自作者仓库的 ellipses/learned_primal_dual.py(椭圆体模实验)和 human/mayo_learned_primal_dual.py(人体数据实验)。
1. 把 CT 算子变成网络层
python
# Create ODL data structures
size = 128
space = odl.uniform_discr([-64, -64], [64, 64], [size, size],
dtype='float32')
geometry = odl.tomo.parallel_beam_geometry(space, num_angles=30)
operator = odl.tomo.RayTransform(space, geometry)
# Ensure operator has fixed operator norm for scale invariance
opnorm = odl.power_method_opnorm(operator)
operator = (1 / opnorm) * operator
# Create tensorflow layer from odl operator
odl_op_layer = odl.contrib.tensorflow.as_tensorflow_layer(operator,
'RayTransform')
odl_op_layer_adjoint = odl.contrib.tensorflow.as_tensorflow_layer(operator.adjoint,
'RayTransformAdjoint')
几点说明:
- 前向算子
operator是 ODL 的RayTransform: 128 × 128 128 \times 128 128×128 图像,30 个角度的平行束; - 用幂迭代估计算子范数
opnorm,再把算子缩放到范数为 1,注释写的目的是"尺度不变"; as_tensorflow_layer把 ODL 算子和它的伴随(反投影)包装成 TensorFlow 层。这两层没有可学习参数,但能参与反向传播:训练时梯度要穿过全部 10 次正投影和反投影,算子部分的梯度由 ODL 提供。
2. 训练数据:随机椭圆 + 5% 高斯噪声
python
for i in range(n_generate):
if validation:
phantom = odl.phantom.shepp_logan(space, True)
else:
phantom = random_phantom(space)
data = operator(phantom)
noisy_data = data + odl.phantom.white_noise(operator.range) * np.mean(np.abs(data)) * 0.05
x_true_arr[i, ..., 0] = phantom
y_arr[i, ..., 0] = noisy_data
训练时每次随机生成椭圆体模,验证时用修改版 Shepp--Logan 体模。噪声是白噪声乘以"投影数据绝对值的均值 × 0.05",对应论文里的"5% 加性高斯噪声"。
3. 核心循环
python
def apply_conv(x, filters=32):
return tf.layers.conv2d(x, filters=filters, kernel_size=3, padding='SAME',
kernel_initializer=tf.contrib.layers.xavier_initializer())
with tf.name_scope('tomography'):
with tf.name_scope('initial_values'):
primal = tf.concat([tf.zeros_like(x_true)] * n_primal, axis=-1)
dual = tf.concat([tf.zeros_like(y_rt)] * n_dual, axis=-1)
for i in range(n_iter):
with tf.variable_scope('dual_iterate_{}'.format(i)):
evalop = odl_op_layer(primal[..., 1:2])
update = tf.concat([dual, evalop, y_rt], axis=-1)
update = prelu(apply_conv(update), name='prelu_1')
update = prelu(apply_conv(update), name='prelu_2')
update = apply_conv(update, filters=n_dual)
dual = dual + update
with tf.variable_scope('primal_iterate_{}'.format(i)):
evalop = odl_op_layer_adjoint(dual[..., 0:1])
update = tf.concat([primal, evalop], axis=-1)
update = prelu(apply_conv(update), name='prelu_1')
update = prelu(apply_conv(update), name='prelu_2')
update = apply_conv(update, filters=n_primal)
primal = primal + update
x_result = primal[..., 0:1]
对照上面的公式读:
- 初值 :原始变量
primal是 5 个全零通道,对偶变量dual也是 5 个全零通道(n_primal = n_dual = 5),和论文一致; - 对偶更新 :
primal[..., 1:2]取原始变量第 2 个通道做正投影,和当前dual、测量数据y_rt拼接,送进三层卷积,输出 5 个通道,再加回dual; - 原始更新 :
dual[..., 0:1]取对偶变量第 1 个通道做反投影,和当前primal拼接,同样三层卷积后加回primal; - 输出 :
primal[..., 0:1],原始变量的第 1 个通道。
"加回去"就是论文里学习近端算子的形式
I d + W w 3 , b 3 ∘ A c 2 ∘ W w 2 , b 2 ∘ A c 1 ∘ W w 1 , b 1 \mathrm{Id} + \mathcal W_{w_3,b_3} \circ \mathcal A_{c_2} \circ \mathcal W_{w_2,b_2} \circ \mathcal A_{c_1} \circ \mathcal W_{w_1,b_1} Id+Ww3,b3∘Ac2∘Ww2,b2∘Ac1∘Ww1,b1
即一个残差块。作者给的理由是:近端算子本来就接近恒等映射,而且残差网络更容易训练。
从代码可以直接数出通道数:对偶网络输入 5(dual)+ 1(正投影)+ 1(数据)= 7 个通道,即 7→32→32→5;原始网络输入 5 + 1(反投影)= 6 个通道,即 6→32→32→5,都是 3 × 3 3 \times 3 3×3 卷积,和论文图 2 一致。每次迭代两个三层网络,10 次迭代共 60 个卷积层 ,但每层只有 32 个通道。按上面的代码逐层算(卷积权重 + 偏置 + PReLU 参数),总参数约 25.3 万,论文给的是"约 2.4 × 10 5 2.4 \times 10^5 2.4×105",量级一致。作为对比,论文里的 U-Net 后处理约 10 7 10^7 107 个参数。
激活函数 prelu 来自作者的工具库 adler(adler/tensorflow/activation.py):
python
def prelu(_x, init=0.0, name='prelu', trainable=True):
with tf.variable_scope(name):
alphas = tf.get_variable('alphas',
shape=[int(_x.get_shape()[-1])],
initializer=tf.constant_initializer(init),
dtype=tf.float32,
trainable=trainable)
pos = tf.nn.relu(_x)
neg = -alphas * tf.nn.relu(-_x)
return pos + neg
每个通道一个可学习的负半轴斜率 alphas,初值 init=0.0,所以训练开始时它就是 ReLU。
4. 损失和优化器
python
with tf.name_scope('loss'):
residual = x_result - x_true
squared_error = residual ** 2
loss = tf.reduce_mean(squared_error)
with tf.name_scope('optimizer'):
# Learning rate
global_step = tf.Variable(0, trainable=False)
maximum_steps = 100001
starter_learning_rate = 1e-3
learning_rate = cosine_decay(starter_learning_rate,
global_step,
maximum_steps,
name='learning_rate')
update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
with tf.control_dependencies(update_ops):
opt_func = tf.train.AdamOptimizer(learning_rate=learning_rate,
beta2=0.99)
tvars = tf.trainable_variables()
grads, _ = tf.clip_by_global_norm(tf.gradients(loss, tvars), 1)
optimizer = opt_func.apply_gradients(zip(grads, tvars),
global_step=global_step)
- 损失是重建结果和真值的均方误差,端到端:误差从最后一次迭代一路反传到第一次迭代;
- Adam,
beta2 = 0.99,其他参数默认; - 全局梯度范数裁剪到 1;
- 学习率从 10 − 3 10^{-3} 10−3 开始按余弦退火,共
maximum_steps = 100001步。cosine_decay同样来自adler(adler/tensorflow/training.py):
python
p = tf.mod(global_step / maximum_steps, 1)
return learning_rate * (0.5 + 0.5 * math_ops.cos(p * np.pi))
即 η t = η 0 ⋅ 1 2 ( 1 + cos ( π t / t max ) ) \eta_t = \eta_0 \cdot \tfrac12\big(1 + \cos(\pi t / t_{\max})\big) ηt=η0⋅21(1+cos(πt/tmax)),从 10 − 3 10^{-3} 10−3 平滑降到接近 0,和论文写的公式一致。
另外从训练循环可以看到:每次生成 n_data = 5 张随机体模(即 batch size 5),每 10 步才重新生成一批(if i%10 == 0)。论文明确说没用 BN、dropout,也没做数据增强,代码里同样没有。
5. 非线性前向模型:直接用"取对数之前"的数据
人体数据实验里,作者用了更符合物理的 Beer--Lambert 定律做前向模型:
T ( f ) = e − μ P ( f ) , ∂ T ( f ) ∗ ( g ) = P ∗ ( − μ e − μ P ( f ) g ) T(f) = e^{-\mu P(f)}, \qquad \\partial T(f)^*(g) = P^*\big(-\mu\, e^{-\mu P(f)}\, g\big) T(f)=e−μP(f),∂T(f)∗(g)=P∗(−μe−μP(f)g)
网络直接吃未取对数的透射数据 。对应代码 human/mayo_learned_primal_dual.py:
python
for i in range(n_iter):
with tf.variable_scope('dual_iterate_{}'.format(i)):
evalpt = primal[..., 1:2]
evalop = tf.exp(-mu_water * odl_op_layer(evalpt))
update = tf.concat([dual, evalop, y_rt], axis=-1)
update = prelu(apply_conv(update), name='prelu_1')
update = prelu(apply_conv(update), name='prelu_2')
update = apply_conv(update, filters=n_dual)
dual = dual + update
with tf.variable_scope('primal_iterate_{}'.format(i)):
evalpt_fwd = primal[..., 0:1]
evalop_fwd = (-mu_water) * tf.exp(-mu_water * odl_op_layer(evalpt_fwd))
evalpt = dual[..., 0:1]
evalop = odl_op_layer_adjoint(evalop_fwd * evalpt)
update = tf.concat([primal, evalop], axis=-1)
update = prelu(apply_conv(update), name='prelu_1')
update = prelu(apply_conv(update), name='prelu_2')
update = apply_conv(update, filters=n_primal)
primal = primal + update
和椭圆版本相比只改了两处,正好对应上面两个公式:
- 对偶更新里,正投影换成
tf.exp(-mu_water * odl_op_layer(evalpt)),即 T ( f ( 2 ) ) T(f^{(2)}) T(f(2)); - 原始更新里,先在
primal[..., 0:1](即 f ( 1 ) f^{(1)} f(1))处算出 − μ e − μ P ( f ( 1 ) ) -\mu e^{-\mu P(f^{(1)})} −μe−μP(f(1)),乘上 h ( 1 ) h^{(1)} h(1) 再反投影,即 ∂ T ( f ( 1 ) ) ∗ ( h ( 1 ) ) \\partial T(f\^{(1)})^*(h^{(1)}) ∂T(f(1))∗(h(1))。
网络结构完全不用改,只换算子 ,这是 LPD 框架通用性的直接体现。论文里 μ \mu μ 取 0.2 cm²/g(约为水的值),代码里 mu_water = 0.02。代码的几何参数(源到旋转中心 500)对应论文的 500 mm,所以这里应是按毫米换算的结果(0.2 cm⁻¹ = 0.02 mm⁻¹)。
仓库里还有一个线性化版本 human/mayo_learned_primal_dual_log.py,先对数据取对数,再用普通 Radon 变换。它的数据生成多了一步把光子计数截断到不小于 1,避免对 0 取对数:
python
noisy_data = odl.phantom.poisson_noise(data * photons_per_pixel)
noisy_data = np.maximum(noisy_data, 1) / photons_per_pixel
log_noisy_data = np.log(noisy_data) * (-1 / mu_water) * (1 / opnorm)
四、实验
1. 两组数据
| 实验 | 图像 | 几何 | 前向模型 | 噪声 |
|---|---|---|---|---|
| 椭圆体模 | 128 × 128 128 \times 128 128×128,随机椭圆训练,Shepp--Logan 验证 | 平行束,30 个角度,182 个探测器单元 | 线性 Radon 变换 | 5% 加性高斯 |
| 人体数据 | 512 × 512 512 \times 512 512×512,Mayo 低剂量挑战赛 3 mm 层厚,9 人训练(2168 张),1 人评估 | 扇束,1000 个角度,1000 个探测器单元,源--轴、轴--探测器各 500 mm | 非线性 Beer--Lambert(另有线性化版本) | 泊松,每个探测器单元入射 10 4 10^4 104 个光子 |
注意这里的"低剂量":椭圆实验是稀疏角度 + 强噪声 ;人体实验是全角度(1000 个)+ 低光子数 。和上一篇 FBPConvNet 的设置(稀疏角度、无噪声、参考图是全角度 FBP)不一样,这里的参考图是体模真值。
对比方法:FBP(Hann 滤波)、TV(经典 PDHG 迭代 1000 次)、作者自己的 Learned Gradient、FBP + U-Net 后处理(即 FBPConvNet 的结构)、Learned PDHG,以及两个简化版:只学原始网络的 Learned Primal,和完全不用前向算子的 FBP + 残差去噪。FBP 的滤波带宽和 TV 的正则参数都按 PSNR 最大来选。
2. 指标怎么算
指标是 PSNR 和 SSIM。评估脚本用的是 skimage 的函数。椭圆实验(ellipses/evaluate_learned_primal_dual.py):
python
print(ssim(primal_values_result[-1][0, ..., 0], x_true_arr_validate[0, ..., 0]))
print(psnr(primal_values_result[-1][0, ..., 0], x_true_arr_validate[0, ..., 0], dynamic_range=1))
人体实验(human/evaluate_mayo_learned_primal_dual.py):
python
print(ssim(primal_values_result[-1][0, ..., 0], x_true_arr_validate[0, ..., 0]))
print(psnr(primal_values_result[-1][0, ..., 0], x_true_arr_validate[0, ..., 0], dynamic_range=np.max(x_true_arr_validate) - np.min(x_true_arr_validate)))
(四)里专门讲过 PSNR 依赖 data_range:这里椭圆实验固定为 1,人体实验取真值图像的最大值减最小值。引用这些数字和别的论文比较时,要先确认 data_range 的取法是否一致。
3. 椭圆体模结果
| 方法 | PSNR (dB) | SSIM | 运行时间 (ms) | 参数量 |
|---|---|---|---|---|
| FBP | 19.75 | 0.597 | 4 | 1 |
| TV | 28.06 | 0.929 | 5166 | 1 |
| FBP + U-Net 去噪 | 29.20 | 0.944 | 9 | 10 7 10^7 107 |
| FBP + 残差去噪 | 32.38 | 0.972 | 9 | 1.2 × 10 5 1.2 \times 10^5 1.2×105 |
| Learned Gradient | 32.29 | 0.981 | 56 | 1.2 × 10 4 1.2 \times 10^4 1.2×104 |
| Learned PDHG | 28.32 | 0.909 | 48 | 2.4 × 10 4 2.4 \times 10^4 2.4×104 |
| Learned Primal | 36.97 | 0.986 | 43 | 1.2 × 10 5 1.2 \times 10^5 1.2×105 |
| Learned Primal-Dual | 38.28 | 0.989 | 49 | 2.4 × 10 5 2.4 \times 10^5 2.4×105 |
- 比 FBP、TV 和 U-Net 后处理都高出 6 dB 以上,这也是摘要里"> 6 dB"的来源;
- 最接近的是 Learned Primal,差 1.3 dB。它和 LPD 的区别在于对偶变量不学习(
n_dual = 1),直接取残差:
python
with tf.variable_scope('dual_iterate_{}'.format(i)):
evalop = odl_op_layer(primal[..., 1:2])
dual = evalop - y_rt
说明大部分增益来自"在网络里反复用前向算子",可学习的对偶网络再多给 1.3 dB;
- Learned PDHG(只把 prox 换成网络)只有 28.32 dB,连 TV 都没明显超过,说明上面那四处修改是必要的;
- 速度:带算子的学习方法比纯后处理慢约 6 倍,但都比 TV 快两个数量级以上。
4. 人体数据结果
| 方法 | PSNR (dB) | SSIM | 运行时间 (ms) | 参数量 |
|---|---|---|---|---|
| FBP | 33.65 | 0.830 | 423 | 1 |
| TV | 37.48 | 0.946 | 64371 | 1 |
| FBP + U-Net 去噪 | 41.92 | 0.941 | 463 | 10 7 10^7 107 |
| LPD,线性 | 44.11 | 0.969 | 620 | 2.4 × 10 5 2.4 \times 10^5 2.4×105 |
| LPD,非线性 | 43.91 | 0.969 | 670 | 2.4 × 10 5 2.4 \times 10^5 2.4×105 |
(论文图 4 中线性版本标注为 44.10 dB,表 II 为 44.11 dB,这里按表格。)
- LPD 比 FBP 高 10.5 dB,比 TV 高 6.6 dB,比 U-Net 后处理高 2.2 dB;
- 一个有意思的现象:U-Net 后处理的 PSNR 比 TV 高 4.4 dB,但 SSIM 反而略低(0.941 vs 0.946);LPD 两项都明显更好。作者的解释是:后处理受限于 FBP 图像所含的信息,而 LPD 直接用数据,受限于数据所含的信息,后者不少于前者;
- 线性和非线性版本结果几乎一样,线性版略好。作者据此认为,在取对数之后的数据上做重建可能更可取;
- 512 × 512 512 \times 512 512×512 时 FBP 本身也要 423 ms,所以 LPD 的 620 ms 已经和 FBP、后处理处在同一量级。
5. 迭代过程的可视化
论文图 5 画了非线性版本第 2、4、6、8、10 次迭代的中间结果:前几次迭代的图像一直很差,到第 8 次时大部分结构才出现,但仍有噪声;第 8 到第 10 次像在做边缘增强。作者据此推测,网络学到的是"先找大尺度结构,再精修细节"的两阶段过程。
五、读这篇论文时要注意的几点
- 评估用的图很少。 椭圆实验的验证图只有一张 Shepp--Logan;人体实验的评估脚本也只对留出病人(L286)的一张固定切片计算指标。论文表格没有给出多张测试图的平均值或方差。
- U-Net 对比是作者重新实现的。 论文说按 FBPConvNet 原文的训练方式效果不理想,所以改用和自己方法相同的训练流程。这是为了公平,但也意味着表格里的 U-Net 结果不等于原作者的实现。
- 几何和噪声水平是固定的。 前向算子在代码开头就定死了(30 角平行束、1000 角扇束),换几何就要重新训练;训练时梯度要穿过 10 次正投影和反投影,人体数据的训练用了约 40 小时(GTX 1080 Ti),椭圆数据约 11 小时。
- 结果偏平滑。 作者在讨论里承认,重建图在视觉上有过度平滑,猜测主要原因是均方误差损失,建议后续尝试感知损失之类的更好的损失函数。
- 环境比较老。 代码基于 TensorFlow 1.x(
tf.placeholder、tf.contrib)和当时 ODL 的开发版本,直接跑需要旧环境;README 写的是预训练模型"即将发布"。
六、它为什么重要
把 LPD 和上一篇的 FBPConvNet 放在一起看,区别很清楚:
| FBPConvNet | Learned Primal-Dual | |
|---|---|---|
| 输入 | FBP 图像 | 原始投影数据 |
| 物理模型 | 只在网络之前用一次 | 每次迭代都用一次正投影和反投影 |
| 网络 | 一个大 U-Net(约 10 7 10^7 107 参数) | 20 个小残差 CNN(约 2.4 × 10 5 2.4 \times 10^5 2.4×105 参数) |
| 上限 | 受 FBP 所含信息限制 | 受数据所含信息限制 |
LPD 证明了:把前向算子嵌进网络、端到端训练,在 512 × 512 512 \times 512 512×512 的临床尺寸上是可行的,而且参数少得多。之后不少展开网络、双域网络,都采用了类似的"数据域网络 + 图像域网络,用正投影和反投影连接"的结构。
这些方向的代表论文、开源代码和数据集,我整理在了一个开源列表里,可以按方向筛选、支持中文搜索:
参考文献
- Adler J, Öktem O. Learned primal-dual reconstruction. IEEE Transactions on Medical Imaging, 2018, 37(6): 1322--1332.
- Chambolle A, Pock T. A first-order primal-dual algorithm for convex problems with applications to imaging. Journal of Mathematical Imaging and Vision, 2011, 40(1): 120--145.
LPD 的代码里,正投影、反投影都是 ODL 调 ASTRA 完成的。下一篇回到基础系列,讲 CT 重建里常用的投影算子库:ASTRA、ODL、TIGRE 等各自能做什么,怎么选。
如果这篇对你有帮助,欢迎收藏,有问题可以在评论区讨论。