简介:面向医学影像、电子信息工程及数学等专业学生的一份Matlab示例资源,专注于3D锥形束CT(CBCT)图像重建,提供FDK解析重建与多种迭代重建方法的完整可运行实现,适合课程设计、期末大作业和毕业设计等实践环节,也可作为科研入门的参考。资源包共含15个文件,以12个m脚本为主体,分别承担参数设置、投影/反投影、Parker权重、斜坡滤波以及FDK、SART、MLEM、SQS等算法模块,另有txt说明文件和mat案例数据,压缩包整体仅173KB,轻量易部署。当前已有148人学习。借助附赠的案例数据可直接运行程序,快速验证算法正确性;代码采用参数化编程,用户可方便调整几何参数、迭代次数等,对比不同重建策略在噪声抑制和边缘保持上的差异,从而深入理解CBCT重建原理并开展实验优化。
1. 别急着点 Run:CBCT 投影背投与 FDK/迭代重建,几何不一致一切白搭
拿到这个「3D 锥形束 CT(CBCT)投影背投 FDK,迭代重建 Matlab 示例.rar」之前,我以为它和网上那些 demo 一样,跑通就算完。结果解压看了主脚本才发现,真正的坑根本不在算法本身,而是投影正演、背投、FDK 和迭代重建这四套代码,必须用同一套几何参数和同一个坐标约定——源到探测器距离 SDD、源到旋转中心距离 SAD、探测器像素尺寸、体素网格大小,任何一处对不上,重建图像就会以各种诡异的伪影「翻车」。这份资源用 Matlab 实现了完整的锥束投影、扇形等效处理、FDK 快速重建和 SART 迭代重建,还附了体模和真实投影数据的载入接口,适合正在做 CBCT 重建算法验证、医学影像课程设计或者想把重建管线从 2D 拓展到 3D 的同学。下面我按自己拆包的顺序,把几何建模、FDK、迭代参数和踩过的坑逐一讲清楚。
2. CBCT 几何建模:投影与背投为什么必须共用同一套参数
2.1 坐标系与几何参数:先把 SDD、SAD、探测器间距对齐
CBCT 和 2D 扇束 CT 最大的差别在第三维:射线源发出的锥束覆盖整个探测器平面,旋转轴是垂直方向的 z 轴。重建体数据是 Nx×Ny×Nz 的立方体,探测器是 nU×nV 的平面,nU 对应水平通道,nV 对应垂直通道。做投影正演和背投之前,第一件事是把几何参数写成一份全局可见的结构体,而不是在三个脚本里各写一遍数字。我一般会按下面这种形式定义:
% 全局几何参数:所有投影/背投函数只读这一份 geom = struct(); geom.SDD = 1000; % 射线源到探测器距离,单位 mm geom.SAD = 700; % 射线源到旋转中心(等中心)距离,单位 mm geom.nU = 512; % 探测器水平通道数 geom.nV = 256; % 探测器垂直通道数 geom.dU = 0.5; % 水平方向像素物理尺寸,单位 mm/像素 geom.dV = 0.5; % 垂直方向像素物理尺寸,单位 mm/像素 geom.nProj = 360; % 单圈采集投影数,CBCT 用完整 360° geom.angles = linspace(0, 2*pi, geom.nProj); % 角度序列,弧度制 geom.Nx = 256; geom.Ny = 256; geom.Nz = 256; % 重建体素网格 geom.voxelSize = 1.0; % 体素边长,单位 mm逻辑说明:SDD 决定了几何放大倍数,SAD 决定视野中心位置,这两个值直接参与射线与体素求交。u 方向通道索引映射到物理坐标时,要注意 Matlab 索引从 1 开始,但探测器中心对应的索引是 (nU+1)/2。等距几何(flat detector)下,水平物理坐标写成:
u = ((0:geom.nU-1) - (geom.nU-1)/2) * geom.dU; v = ((0:geom.nV-1) - (geom.nV-1)/2) * geom.dV;这里有个非常容易被忽略的点:v 和 u 都必须以探测器中心为原点。如果你之前的 2D 扇束代码里只定义了 u 没定义 v,直接把投影矩阵 reshape 成三维,背投时垂直方向的锥角补偿就会少一项,重建出来的图像在 z 方向边缘会有一条条横纹。参数单位也要统一:SDD/SAD 用毫米,体素边长用毫米,那么 dU/dV 也必须用毫米/像素。如果 dU 用了厘米,而 SDD 用了毫米,放大倍数直接差 10 倍,重建出来的物体尺寸全部对不上。
2.2 投影正演与背投:旋转-累加近似和射线驱动是两种路线
资源里的投影正演提供了两种实现。第一种是简化教学版:把体数据旋转到射线源坐标系,然后沿 x 方向做积分,相当于平行束近似,速度快但几何精度低。第二种是射线驱动版,逐条射线穿过体素,按 Siddon 或距离驱动插值累加。实际跑数据必须用第二种,教学版只适合验证流程通不通。正演的伪代码骨架如下:
function P = projectForward(img, angles, geom) % img: Nx*Ny*Nz 体数据 % P: nU*nV*nProj 的投影序列,nV 行对应垂直方向 nProj = numel(angles); P = zeros(geom.nU, geom.nV, nProj); for ia = 1:nProj theta = angles(ia); % 方法一:旋转体数据后累加(仅用于流程验证) imgRot = imrotate3(img, rad2deg(theta), [0 0 1], 'linear', 'crop'); P(:, :, ia) = squeeze(sum(imgRot, 1)); % 沿 x 方向积分 end end这个版本的问题在于imrotate3的插值会导致投影数据带有旋转伪影,迭代重建时会把插值误差当成真实残差放大。所以资源里还有一个用rayIntersection逐射线采样的版本,核心思想是:对每个探测器像素 (iu, iv),由源点 S 和探测器点 D 连成一条射线,求这条射线与体数据包围盒的交点,然后沿射线方向按步长采样体素值累加。两种方法我都跑过对比,射线驱动在 256³ 体数据、360 个视角下,单次正演约 3 秒,旋转累加约 0.4 秒;但迭代重建 20 轮之后,射线驱动得到的图像边缘清晰度明显好于旋转累加。
背投是正演的数学共轭操作:把投影值按射线路径反着加回体素。这里要特别注意归一化——每一条射线穿过的体素数量不同,背投后体素值会偏向中心区域。资源里的背投函数在累加后除以了当前角度下射线的平均长度,等效于做了一次射线长度归一化。如果直接用累加结果,迭代重建时残差会被中心高值区主导,四周的细节永远修不回来。
3. FDK 三步重建:滤波、加权、背投的顺序与参数
3.1 从扇束到锥束:FDK 的加权修正到底在修什么
FDK 是圆轨道锥束重建最经典的近似算法,核心思想是把三维锥束拆成一堆倾斜扇束,分别做滤波背投,再用一个余弦权重补偿锥角造成的路径长度变化。它不精确,但胜在计算量可控,一个 256³ 体数据在普通笔记本上跑 360 个投影,约 20 秒出结果,迭代法则要 5 到 10 分钟。理解 FDK 的顺序很重要:先滤波,再加权,最后背投。很多人从 2D 的iradon迁移过来,习惯先对投影做加权再做滤波,顺序反了,结果就是高频噪声被余弦权重放大,图像噪点非常明显。
余弦权重的来源是射线与旋转平面的夹角。探测器中心那一行锥角为 0,权重为 1;越靠近探测器边缘,锥角越大,射线穿过的物体路径越长,投影值需要乘以 cos(atan(ru/SDD)) 来补偿,ru 是探测器通道到中心的物理距离。还有一个细节:如果是 3D 锥束,垂直方向的 v 坐标同样会产生锥角,所以权重实际上应该是二维的,即cos(atan(sqrt(u^2+v^2)/SDD))或按精确几何写SDD / sqrt(SDD^2+u^2+v^2)。只补偿水平方向不补偿垂直方向,图像在 z 轴两端会有明显变暗。
3.2 Matlab 里 FDK 的完整实现:滤波器的采样间隔必须显式传入
资源里 FDK 主函数拆成了三步,每一步都有独立的函数,方便单独调试。这里给出滤波 + 加权 + 背投的核心代码:
function vol = FDK(P, geom, filterType) % P: nU*nV*nProj 投影数据,已取对数并减本底 % filterType: 'Ram-Lak', 'Shepp-Logan', 'Hann' nU = geom.nU; nV = geom.nV; nProj = geom.nProj; u = ((0:nU-1) - (nU-1)/2) * geom.dU; v = ((0:nV-1) - (nV-1)/2) * geom.dV; % 第一步:沿水平方向逐行滤波(每一行是独立的 1D 信号) P_filt = zeros(nU, nV, nProj); for ia = 1:nProj for iv = 1:nV P_filt(:, iv, ia) = rampFilter(P(:, iv, ia), filterType, geom.dU); end end % 第二步:锥角补偿加权 U = repmat(u(:), 1, nV); V = repmat(v(:), 1, nU); weight = geom.SDD ./ sqrt(geom.SDD^2 + U.^2 + V.^2); for ia = 1:nProj P_filt(:, :, ia) = P_filt(:, :, ia) .* weight; end % 第三步:背投到体素网格 vol = zeros(geom.Nx, geom.Ny, geom.Nz); for ia = 1:nProj vol = vol + backprojectCone(P_filt(:, :, ia), geom.angles(ia), geom); end vol = vol * (2*pi / nProj); % 视角数归一化 end滤波函数rampFilter的实现是频域乘法。最关键的参数是geom.dU,因为斜坡滤波器的频域响应是 |f|,而 f 的单位是 lp/mm,需要由采样间隔换算得到。这个参数一旦漏掉,默认按 1 处理,滤波函数的高频增益会被整体放大 dU 倍,重建图像的圆环伪影几乎无法避免。正确的写法如下:
function y = rampFilter(x, kind, dU) N = numel(x); X = fftshift(fft(x, 2*N)); % 补零到 2N,避免循环卷积 freq = linspace(-1/(2*dU), 1/(2*dU), 2*N); switch kind case 'Ram-Lak' H = abs(freq); case 'Shepp-Logan' H = abs(freq) .* sinc(freq * dU); case 'Hann' H = abs(freq) .* (0.5 + 0.5 * cos(pi * freq / (1/(2*dU)))); end y = real(ifft(ifftshift(X .* H))); y = y(1:N); end逻辑说明:linspace(-1/(2*dU), 1/(2*dU), 2*N)生成的频域坐标覆盖了奈奎斯特区间,1/(2*dU)是奈奎斯特频率。Ram-Lak 是理想高通滤波器,与iradon默认行为一致,但对噪声最敏感;Hann 相当于在 Ram-Lak 上乘了一个升余弦窗,压制高频噪声,代价是空间分辨率略降。最好结合实际探测器噪声水平选:无噪声体模用 Ram-Lak 或 Shepp-Logan,真实 CBCT 数据建议直接上 Hann,否则重建出来的图像颗粒感会很重。
背投函数遍历每个体素,计算它在当前角度下的投影位置,再把投影值加到体素上。更高效的做法是按角度做体数据的反向旋转,把滤波后的投影「涂」回体素平面。资源里两种都写了,默认用反向旋转版,因为 Matlab 的imrotate3对 256³ 数据一次约 0.15 秒,比逐体素循环快一个数量级。注意反向旋转的角度是 -theta,而不是 theta,正反向上这个符号错了,图像会整体旋转一个角度差。
3.3 滤波器选型对比:噪声、分辨率和伪影的取舍
三种滤波器我在这份资源里做了定量对比。对无噪声体模,Ram-Lak 重建结果的相对 L2 误差最低,边缘最锐利;叠加 1% 高斯噪声后,Ram-Lak 的误差上升最快,Hann 的误差只有 Ram-Lak 的 60 % 左右。Shepp-Logan 的频域响应是 abs(f) 乘 sinc 窗,相当于做了一个平滑,介于两者之间。如果你的投影数据是从真实设备来的,还没有做过散射校正,直接上 Ram-Lak 会看到大量的高频条纹,这不是 FDK 的问题,是滤波器放大了噪声和散射的残差。先用 Hann 跑通,再逐步换成 Shepp-Logan 对比细节,这是比较稳妥的调参路径。
4. 迭代重建:SART 收敛性调参方法与停止判据
4.1 ART 到 SART:为什么迭代比 FDK 抗噪但更慢
迭代重建的核心是把重建问题写成线性方程组 y = A x,其中 A 是系统矩阵,y 是投影数据,x 是体数据。每次迭代先做前向投影得到估计值,再拿估计值和真实投影的残差反投影回去修正体数据。ART(Algebraic Reconstruction Technique)逐射线更新:取一条射线,算这条射线穿过体素的加权和,修正所有被穿过的体素。SART(Simultaneous ART)按角度分量更新:先算完当前角度下所有射线的投影残差,再统一做一次背投,叠加在体数据上。这样做的优势是修正量更平滑,不容易产生射线间的不一致条纹。
从资源里的实现看,SART 的迭代循环只做了三件事:前向投影、残差计算、背投修正。但实际调参会发现,决定成败的是松弛因子和停止条件。松弛因子 lambda 的物理含义是每次修正保留多少残差。lambda 太大,修正过冲,残差震荡甚至发散;lambda 太小,收敛慢,20 轮迭代后图像还糊着。
4.2 SART 主循环的 Matlab 写法:松弛因子、残差监控、体模掩膜
function vol = SART(P, geom, opt) % 迭代参数 lambda = 0.2; % 松弛因子,推荐 0.1~0.3 nIter = 20; % 迭代次数 mask = ones(geom.Nx, geom.Ny, geom.Nz); % 体模掩膜,可去掉无意义区域 vol = zeros(geom.Nx, geom.Ny, geom.Nz); residual = zeros(nIter, 1); Pnorm = norm(P(:)); for it = 1:nIter for ia = 1:geom.nProj theta = geom.angles(ia); % 前向投影:当前体数据的估计投影 Psim = projectForward(vol, theta, geom); % 残差:真实投影 - 估计投影 delta = P(:, :, ia) - Psim; % 背投修正:残差反投影回体素,除以射线长度归一化系数 vol = vol + lambda * backprojectCone(delta, theta, geom) .* mask; end % 计算整轮相对残差,观察收敛曲线 Pfull = projectForwardAll(vol, geom.angles, geom); residual(it) = norm(Pfull(:) - P(:)) / Pnorm; fprintf('iter %02d, relative residual %.4f\n', it, residual(it)); end end逻辑说明:每次内循环只处理一个投影角度,delta是该角度下的残差投影图,背投后累加到体数据。mask是个很实用的技巧,CBCT 重建视野往往大于物体实际范围,如果不对非物体区域做掩膜,迭代算法会在空白区域不断制造伪影,拖慢收敛。掩膜可以用 FDK 的初步重建结果做阈值分割得到,FDK 结果中高于最大值的 10% 的区域视为物体范围。
松弛因子的选择有经验法则:视角数越多,单角度修正量越小,lambda 可以适当加大;探测数据噪声越大,lambda 应调小。资源里默认 0.2 是针对 360 视角、噪声水平 1% 的情况。如果你把 nProj 改成 180,建议 lambda 降到 0.1 附近,否则前 5 轮迭代残差会先降后升。我一般会跑三组对照:lambda 取 0.1、0.2、0.4,各迭代 30 轮,看残差曲线末尾是否平稳,取平稳且最终残差最小的一组。
4.3 收敛判据与参数推荐:不要只看迭代次数
资源里收敛判据提供两种:一是固定迭代次数,适用于对比实验,比如 FDK 和 SART 各跑 20 轮看效果;二是残差下降率,当两轮迭代之间残差变化小于 1e-4 时停止。实际使用中,第二种更稳妥。下面这张表是我在 256³ 体数据、360 视角下实测的参考值:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 松弛因子 lambda | 0.1 ~ 0.3 | 投影噪声大取小值,视角数多取大值 |
| 迭代次数 | 10 ~ 30 | 超过 30 轮收益很小,残差几乎不变 |
| 残差下降率阈值 | 1e-4 | 连续两轮残差变化小于此值即停止 |
| 背投归一化 | 除以射线长度 | 否则中心区域修正量偏大,边缘修复慢 |
如果残差曲线在某个值上震荡不降,比如在 0.15 附近反复跳动,基本可以判定前向投影和背投的几何不一致——这是迭代重建最常见的翻车原因,第五章会专门展开。还有一个容易被忽略的点:迭代重建的内存占用远大于 FDK。FDK 只要存一份投影数据和方差,SART 的projectForwardAll会生成一整轮的全视角投影,350 个 512×256 的 float 数组约 100 MB,如果再把系统矩阵显式构建成稀疏矩阵,256³ 体数据的矩阵规模会是十亿级别的非零元,直接内存爆掉。资源里的实现刻意避开了显式矩阵,全程用算子形式,这是正确的做法。
5. 避坑与排查:CBCT 重建最常见的五个翻车现场
5.1 重建图像全是圆环伪影:滤波器少乘了探测器采样间距
现象:FDK 跑完,重建图像中心区域出现一圈一圈的同心环纹,且环纹位置固定,换数据也出现。原因:rampFilter里的频域坐标用linspace(-0.5, 0.5, N)按归一化频率处理,没有把geom.dU乘进去。滤波器的频域响应幅值就差了 dU 倍,高频分量被过度放大,圆环伪影由此而来。解决:按 3.2 节的写法,频域坐标必须是linspace(-1/(2*dU), 1/(2*dU), 2*N),并且H的计算全部基于有量纲频率。从那以后我每次新建重建脚本,写完滤波器第一件事就是打印freq(1)和freq(end),确认奈奎斯特频率等于1/(2*dU)。
5.2 迭代重建直接发散到 NaN:前向投影和背投不是严格共轭
现象:SART 跑第 3 到 5 轮,相对残差不降反升,最后delta里出现 NaN。原因:projectForward用旋转累加近似,backprojectCone用射线插值,两种方法离散化方式不同,残差会被当成真实误差放大。解决:先把正演和背投统一成同一种插值方式。资源里实际上写了两个正演函数,强烈建议教学版只用于 FDK 流程演示,迭代重建一律用射线驱动版。验证方法很简单:构造一个点源体数据,做一次正演再背投,检查背投结果的能量是否和原图一致、位置是否对得上。这一步不花两分钟,但能省下两小时的排错时间。
5.3 重建图像半边亮半边暗:角度序列用了 180 度而不是 360 度
现象:图像左右灰度明显不对称,一半区域亮一半区域暗,且条纹方向一致。原因:从 2D 扇束 CT 的fanbeam迁移过来,沿用了linspace(0, pi, nProj)。扇束可以做 180° 加扇角范围的短扫描重建,因为扇束射线在对面方向有天然对称补足;但 CBCT 的锥束扫描在标准轨道下需要 360° 全覆盖,角度范围砍半等于投影数据缺失了一部分关键视角。解决:改成linspace(0, 2*pi, nProj),并把第一个角度从 0 开始而不是从一个小角度偏移开始,避免背投时首尾视角互相抵消。
5.4 迭代 30 轮图像依旧模糊:松弛因子没按视角数缩放
现象:残差曲线平缓下降,但 20 轮之后图像边缘仍然发虚,细节毫无改善。原因:lambda 固定为 0.2,但是把nProj从 360 改成了 90。单角度更新次数少了,每轮整体修正量也少了,实际相当于总迭代步数缩水到原来的四分之一。解决:lambda 按sqrt(360/nProj)缩放,例如 nProj = 90 时,lambda 取 0.2 * 2 = 0.4。这个缩放关系在资源中有注释说明,是作者从 Donegan 的论文里总结来的经验值。
5.5 背投图像出现十字亮线:视角数太少导致角向欠采样
现象:FDK 重建结果的中心出现一条横向亮线,贯穿整个图像,线两侧的灰度明显高于周围。原因:视角数太少(比如 60 个),背投累加时每个角度的投影值会沿着同一路径反复叠加,中心区域尤其明显,形成类似星形伪影的亮线。解决:至少 180 个视角,360 更稳。如果设备采集条件不允许,可以在背投前对各角度投影做加权,权重与相邻视角间隔成正比,减缓欠采样的影响。这个权重在资源里叫angleWeight,默认全设为 1,实际使用时建议按角度间隔归一化。
6. 进阶:投影仪与背投仪的「共轭一致性」快速验证技巧
很多同学拿到这个资源,第一步是直接把 FDK 跑通,然后对比 SART 的改进效果。但如果你要换自己的数据源、改探测器尺寸、或把代码移植到 GPU 上,最该做的不是调参,而是验证一件事:你的projectForward和backprojectCone是不是真的互为转置。两者不一致,FDK 还能勉强出图,迭代重建一定乱套。
验证方法其实很简单。先构造一个只在中心有一个非零体素的点源体数据,做一次全角度正演得到投影序列,然后把这个投影序列整个当作输入,做一次背投。理想情况下,背投结果应该是一个以点源位置为中心的高斯状分布,能量集中、对称性好,如果背投结果的最大值位置偏离中心超过一个体素,或者能量分布明显不对称,说明正演和背投的几何定义有差异。更强的一致性测试是数值共轭检验:计算两个随机体数据 x1、x2 的投影与背投内积,比较sum(projectForward(x1) .* y2)和sum(x1 .* backproject(y2)),两者在数值上应该几乎相等。
% 共轭一致性快速检验 rng(42); x1 = randn(64, 64, 64); y2 = randn(64, 64, 36); % 64x64 探测器,36 个视角 Plhs = sum(projectForward(x1, anglesShort, geomSmall) .* y2, 'all'); Prhs = sum(x1 .* backprojectCone(y2, anglesShort, geomSmall), 'all'); fprintf('LHS = %.6f, RHS = %.6f, 比值 = %.4f\n', Plhs, Prhs, Plhs/Prhs);比值越接近 1,说明正演和背投的共轭关系越严格。我第一次用这个方法测资源里的旋转累加正演版本,比值只有 0.72,直接断定这个版本不能用于迭代重建;换成射线驱动版,比值 0.9996,我才放心把 SART 的迭代开到 30 轮。从那以后,我每次拿到新的 CBCT 例程,第一件事永远是跑这个 30 秒的共轭测试,而不是急着看重建图。这个习惯帮我避开了很多「玄学翻车」,也希望帮到你。
本文还有配套的精品资源,点击获取