做 CT 重建,绕不开三个词:Radon 变换、正弦图、FBP。后面的迭代重建、稀疏视角、扩散模型,几乎每篇论文都拿 FBP 当基线。这篇用最少的公式把它们讲清楚,最后给一段可以直接运行的 Python 代码,亲手看一遍"投影 → 正弦图 → 重建"的全过程。
这是「CT 重建从零开始」系列的第一篇。
一、CT 到底测了什么
X 射线穿过物体会衰减。沿一条射线 L,探测器读到的强度满足 Beer--Lambert 定律:
I = I 0 exp ( − ∫ L μ ( x , y ) d l ) I = I_0 \exp\left(-\int_L \mu(x,y)\,dl\right) I=I0exp(−∫Lμ(x,y)dl)
其中 μ \mu μ 是线性衰减系数,也就是我们想重建的图像。两边取对数:
p = − ln I I 0 = ∫ L μ ( x , y ) d l p = -\ln\frac{I}{I_0} = \int_L \mu(x,y)\,dl p=−lnI0I=∫Lμ(x,y)dl
所以 CT 每一个探测器读数,经过对数变换后,就是图像沿一条直线的线积分。重建要回答的问题是:已知大量线积分,反推出图像本身。
二、Radon 变换:把所有线积分排好
用角度 θ \theta θ 和到中心的距离 s s s 描述一条直线,平行束下的投影就是 Radon 变换:
p ( θ , s ) = ∬ f ( x , y ) δ ( x cos θ + y sin θ − s ) d x d y p(\theta, s) = \iint f(x,y)\,\delta(x\cos\theta + y\sin\theta - s)\,dx\,dy p(θ,s)=∬f(x,y)δ(xcosθ+ysinθ−s)dxdy
固定一个角度 θ \theta θ,扫过所有 s s s,得到一条一维投影;角度从 0° 转到 180°(平行束转半圈就够,另一半是重复的),就得到全部数据。
三、正弦图:为什么叫"正弦"
把 p ( θ , s ) p(\theta, s) p(θ,s) 画成一张二维图,横轴角度、纵轴探测器位置,就是正弦图(sinogram)。
原因很直观:图像里一个点 ( x 0 , y 0 ) (x_0, y_0) (x0,y0),在角度 θ \theta θ 下投影到的位置是 s = x 0 cos θ + y 0 sin θ s = x_0\cos\theta + y_0\sin\theta s=x0cosθ+y0sinθ,这正是一条正弦曲线。整幅图像是无数个点叠加,所以正弦图就是无数条正弦曲线叠在一起。离中心越远的点,曲线振幅越大。
四、直接反投影为什么是糊的
最朴素的想法:把每个角度的投影值沿原路"抹"回图像,所有角度叠加起来,叫反投影(backprojection)。
问题在于,中心附近每个角度都会被抹到,越靠近中心叠加得越多。可以证明,直接反投影的结果等于真实图像和 1 / r 1/r 1/r 核做卷积,所以低频被严重放大,图像一片模糊(见下图第三张)。
五、中心切片定理与斜坡滤波
解决方法来自中心切片定理:一个角度投影的一维傅里叶变换,等于图像二维傅里叶变换中,过原点、同一方向的那条切片。
也就是说,转半圈采集投影,相当于在频域里沿放射线采样。放射状采样在中心密、在外围稀,密度正比于 1 / ∣ ω ∣ 1/|\omega| 1/∣ω∣。要补偿这种不均匀,就要给每个频率乘上 ∣ ω ∣ |\omega| ∣ω∣,这就是斜坡滤波器(ramp filter)。
于是 FBP(Filtered Back-Projection,滤波反投影)只有两步:
- 对每个角度的投影做一维斜坡滤波;
- 把滤波后的投影反投影回图像。
斜坡滤波会放大高频,也就会放大噪声。所以实际中常给它乘一个窗函数,比如 Shepp--Logan、Cosine、Hamming、Hann,牺牲一点分辨率换取更低的噪声。
六、代码:30 行跑通 FBP
只依赖 numpy、matplotlib 和 scikit-image(pip install scikit-image matplotlib):
python
import numpy as np
import matplotlib.pyplot as plt
from skimage.data import shepp_logan_phantom
from skimage.transform import radon, iradon, rescale
from skimage.metrics import peak_signal_noise_ratio as psnr
img = rescale(shepp_logan_phantom(), 0.64) # 256x256 Shepp-Logan 体模
theta = np.linspace(0., 180., 180, endpoint=False) # 180 个角度,步长 1°
sino = radon(img, theta=theta) # 正弦图:探测器位置 x 角度
bp = iradon(sino, theta=theta, filter_name=None) # 直接反投影(不滤波)
fbp = iradon(sino, theta=theta, filter_name="ramp") # 滤波反投影
sparse = theta[::6] # 只保留 30 个角度
fbp30 = iradon(radon(img, theta=sparse), theta=sparse, filter_name="ramp")
score = lambda x: psnr(img, np.clip(x, 0, 1), data_range=1)
print(f"FBP 180 views: {score(fbp):.2f} dB")
print(f"FBP 30 views: {score(fbp30):.2f} dB")
titles = ["Phantom", "Sinogram", "Backprojection", "FBP 180 views", "FBP 30 views"]
fig, ax = plt.subplots(1, 5, figsize=(17, 3.6))
for a, t, x in zip(ax, titles, [img, sino, bp, fbp, fbp30]):
a.imshow(x, cmap="gray", aspect="auto" if t == "Sinogram" else "equal")
a.set_title(t); a.axis("off")
plt.tight_layout(); plt.show()
运行结果(scikit-image 0.26):

- 180 个角度的 FBP:PSNR 30.91 dB,和原图几乎一样;
- 不滤波的直接反投影:一团模糊,只能看出轮廓;
- 只用 30 个角度:PSNR 掉到 22.14 dB,满图放射状的条纹伪影。
七、换个滤波器会怎样
同样 180 个角度、无噪声数据,换不同窗函数:
| 滤波器 | PSNR (dB) |
|---|---|
| Ramp | 30.91 |
| Shepp--Logan | 29.94 |
| Cosine | 28.19 |
| Hamming | 27.14 |
| Hann | 26.85 |
无噪声时,纯斜坡最好,窗越"软"越模糊。
再模拟一次低剂量。注意体模的灰度只是相对值,直接拿它算正弦图,线积分最大约 66,按 e − 66 e^{-66} e−66 衰减几乎没有光子能到达探测器。所以先把体模缩小 10 倍,让线积分最大约 6.6,回到真实 CT 的量级,再按入射光子数 I 0 = 10 4 I_0 = 10^4 I0=104 加泊松噪声:
python
mu = img / 10 # 缩到真实衰减量级,线积分最大约 6.6
sino_mu = radon(mu, theta=theta)
rng = np.random.default_rng(0) # 固定随机种子,结果可复现
I0 = 1e4 # 入射光子数
counts = rng.poisson(I0 * np.exp(-sino_mu)) # 探测器计数(泊松分布)
sino_noisy = -np.log(np.maximum(counts, 1) / I0)
for f in ["ramp", "shepp-logan"]:
rec = 10 * iradon(sino_noisy, theta=theta, filter_name=f) # 乘回 10
print(f, f"{score(rec):.2f} dB")
结果:Ramp 27.66 dB ,Shepp--Logan 27.95 dB,Shepp--Logan 反超了 Ramp。换随机种子,数字会有 0.1 dB 左右的浮动,但排序不变。
这就是窗函数存在的意义:噪声越大,越需要压高频。
八、FBP 的天花板,就是后面所有方向的起点
FBP 快、稳定、可解释,至今仍是临床 CT 的重要基础。但它对数据的要求很高:
- 角度不够(稀疏视角):条纹伪影,就是上面 30 个角度那张图;
- 剂量太低:噪声被斜坡滤波放大;
- 角度范围不足(有限角):某些方向的边缘直接丢失;
- 几何不是平行束:实际 CT 多是扇束和锥束,需要扇束 FBP 和 FDK 算法。
这几个问题,分别对应了稀疏视角 CT、低剂量 CT、有限角层析、锥束 CT 几个研究方向。这些方向的代表论文、开源代码和数据集,我整理在了一个开源列表里,可以按方向筛选、支持中文搜索:
下一篇
「CT 重建从零开始(二)」会讲迭代重建:把 CT 写成线性方程组 A x = b Ax = b Ax=b,从 ART、SART 到加入全变分(TV)正则,看看它们怎么在 30 个角度下把条纹压下去。
如果这篇对你有帮助,欢迎收藏,有问题可以在评论区讨论。