第一次把 ASTRA Toolbox 拖进项目里,是为了解决一个很具体的问题:手上只有一堆不同角度下拍到的投影数据,需要反推出物体内部的结构分布,而且必须能复现、能对比、能调参。当时试过自己写滤波反投影,几十行代码能出图,但一换几何、一加噪声、一上三维,各种边界问题就冒出来了。后来换成 ASTRA Toolbox,最大的感受是它不给你一个“一键重建”的黑盒,而是把整个断层重建拆成几何、投影器、数据对象、算法四块积木,让你自己搭。这个设计一开始很劝退,习惯之后反而离不开——因为任何一个环节出问题,你都能定位到具体是哪块积木放错了位置。
这篇笔记面向三类人:刚接触断层重建、想知道这套工具箱怎么跑通第一条链路的;已经在用、但被几何参数和伪影折腾得怀疑人生的;以及需要把重建流程嵌进自己的处理管线、对性能和资源有要求的。内容从概念拆解到完整代码、从参数计算到踩坑记录,尽量把每一步“为什么这么设”讲清楚。文中所有代码都是 2D/3D 两条线并行讲的,数值和参数可以直接抄,但几何那一节建议逐字看,那是绝大多数人翻车的起点。
1. 这套工具箱到底在解决什么问题
1.1 它不做端到端,而是把重建拆成四块积木
断层重建的核心任务可以用一句话概括:已知物体在不同角度下的线积分(投影),求物体本身的空间分布。数学上这是一个大型线性方程组的求解问题,只是矩阵大到没法显式存下来。工业界处理这类问题的思路有两派:一派把整个流程封成一个函数,输入投影、输出图像,简单但不可控;另一派把矩阵-向量乘法暴露出来,让你自己决定用哪种算法、加什么约束、迭代多少轮。ASTRA Toolbox 属于后者,而且是暴露得比较彻底的那一类。
它把一次重建拆成四个角色。几何(geometry)描述扫描是怎么做的:平行束还是锥束、探测器有多少像素、间距多少、转了多少个角度。投影器(projector)是几何的“执行者”,负责真正把三维物体投影成二维数据,或者反过来把二维数据反投影回三维空间,它决定了内部用哪套数值离散方式。数据对象(data object)是挂在工具箱内部的一块显存或内存,里面存着投影数据或重建结果。算法(algorithm)则是迭代或解析的重建过程本身,它拿着投影器和数据对象的句柄干活。
这种拆分看起来啰嗦,但好处很直接:同一次扫描的几何只需要建一次,之后想跑 FBP、想跑 SIRT、想跑 CGLS 对比效果,只需要换算法对象,几何和投影器复用。我之前做算法对比实验,一套几何配五种算法,代码里就是循环换个字符串,省了大量重复配置的时间。反过来,如果你曾经因为“改了重建算法但忘了同步改前向投影的几何”而得到一堆对不上的结果,就会明白这种显式拆分其实是保护。
1.2 和自写 FBP、和通用优化库的取舍
自己写滤波反投影其实不难,核心就是“补零—傅里叶变换—乘滤波核—反变换—反投影”这几步,一百行以内能搞定。那为什么还要用工具箱?我的实际体会是三点。第一,几何泛化。平行束的 FBP 公式很干净,一换到扇束、锥束,权重项和滤波前重排的细节就变得很琐碎,这部分自己推一遍至少要花掉两三天,还容易在边界处理上出错。第二,投影算子的一致性。做迭代重建时,前向投影和反投影必须是严格共轭的,否则迭代不收敛或者收敛到奇怪的地方;工具箱内部保证了这个性质。第三,GPU 落地。自己写 CUDA 反投影是一件投入产出比很低的事。
那为什么不用通用的优化库直接解?因为 CT 的矩阵太大,显式构造稀疏矩阵在 512³ 这个量级就已经不可行了。工具箱提供的OpTomo接口可以把投影算子包装成线性算子对象,接入外部的迭代求解器,这样既保留了通用优化的灵活性,又不用真的把矩阵存下来——这个口子在我需要加自定义正则项时特别好用。
提示:如果你的任务只是“验证一个想法”,自写 FBP 完全够用;一旦涉及多几何对比、迭代算法、三维数据,早点换工具箱,省下来的时间远超学习成本。
1.3 装完第一件事:跑自检,别急着写业务代码
安装这一步有个容易被忽略的细节:通过包管理器拿到的预编译包,CPU 后端一定是可用的,GPU 后端是否可用取决于打包方式和本机驱动环境,不是装完就一定有。所以装好之后第一件事不是写重建代码,而是确认后端状态。
import astra import numpy as np print("版本:", astra.__version__) # 检查 GPU 后端是否可用,返回布尔值 print("GPU 可用:", astra.use_cuda()) # 工具箱自带的一批快速自检用例,跑一遍能暴露大部分环境问题 astra.test()自检跑完,如果 GPU 那一项是False,先别急着重装。按我的排查顺序:先确认驱动版本和预编译包要求的版本是否匹配,再确认是否有其他进程占着显存,最后才考虑换安装源。我见过好几次是显存被上一轮没释放的进程占满,误以为是环境坏了,白白重装了两小时。
另外一个小习惯:每次实验开始前打印一次版本号和 GPU 状态,写进日志。后期复盘时经常会出现“同样的脚本换台机器结果不一样”的情况,有这条日志就能立刻判断是不是后端差异导致的。
2. 几何、投影器、数据对象、算法:四个核心概念的拆解
2.1 几何参数:整个流程里最容易错的一环
几何是这套工具箱里信息密度最高、也最容易配错的部分。二维平行束的几何创建只需要四个信息:探测器像素间距、探测器像素数量、投影角度列表,以及可选的重建体积尺寸。
import numpy as np import astra N = 256 # 重建体积 256 x 256 det_width = 1.0 # 探测器像素间距,单位与体素尺寸一致 n_angles = 180 angles = np.linspace(0, np.pi, n_angles, endpoint=False) vol_geom = astra.create_vol_geom(N, N) proj_geom = astra.create_proj_geom('parallel', det_width, N, angles)这里有三处必须说清楚。第一,角度单位是弧度不是度。这个坑太经典了,写np.linspace(0, 180, 180)进去不会报错,重建出来的图像会是一团旋转对称的糊,你会以为是算法有问题,其实是角度范围错了整整 57 倍。第二,平行束的角度范围是 0 到 π,不是 0 到 2π。因为平行束在 θ 和 θ+π 两个方向上的投影是镜像关系,转到 180° 就已经采全了信息,多采一倍纯属浪费。第三,探测器间距和体素尺寸必须是同一个物理单位。如果投影数据来自真实设备,探测器间距单位是毫米,那重建体积的体素尺寸也得按毫米来算,否则重建出来的尺寸会整体缩放。
角度数量怎么定,有个经验公式可以直接用:平行束在 180° 范围内的采样数应满足n_angles ≥ (π/2) × n_det,这是从 Nyquist 采样条件推出来的。256 个探测器像素对应约 402 个角度,实际工程里取 360 到 400 就足够,取 180 会明显欠采样,图像上会出现放射状的条状伪影。当然角度越多采集越慢、重建越久,这个平衡点要按实际场景调。
三维的几何创建多两个维度:
vol_geom = astra.create_vol_geom(256, 256, 256) # (y, x, z) 顺序要注意 angles = np.linspace(0, np.pi, 360, endpoint=False) proj_geom = astra.create_proj_geom( 'parallel3d', 1.0, 256, 256, angles # 探测器间距、行数、列数 )注意:三维体积的维度顺序是
(切片数, 行, 列),对应(z, y, x);二维投影数据的顺序是(角度数, 探测器像素数)。这两个顺序如果搞反,得到的可能是一张能看但方向全错的图,这种错误最隐蔽,一定要用已知形状的仿真体模验证。
2.2 投影器:CPU 和 GPU 的取舍不是“哪个快”
几何建完之后要建投影器。投影器的类型决定了内部用什么数值模型来近似“一条射线穿过一个像素”这件事。二维可选的有按线积分近似的、按条带近似的、以及 GPU 加速版本;三维基本就是 GPU 版和线性版两种。
# 二维:GPU 投影器(推荐,前提是后端可用) projector_id = astra.create_projector('cuda', proj_geom, vol_geom) # 二维:CPU 线性投影器(调试用,行为更可预测) projector_id = astra.create_projector('linear', proj_geom, vol_geom)选择逻辑不是单纯的快慢。GPU 投影器速度快一个到两个数量级,但有两个约束:一是某些解析类算法(比如带滤波的 FBP 的 GPU 版本)对几何和投影器的组合有额外要求;二是显存占用和问题规模成正比,三维 512³ 的数据很容易把显存吃满。CPU 投影器的价值在于调试——当结果不对时,先用 CPU 版跑一遍,能排除掉一大类 GPU 特有的数值精度和内存问题,把问题范围缩小。
还有一个细节:投影器的数值模型会影响重建细节。线模型和条带模型在高频区域的响应不同,条带模型对边缘的建模更精细,但也更慢。我一般做算法对比时固定用同一种,避免把模型差异误读成算法差异。
2.3 数据对象与资源生命周期
数据对象是工具箱内部的句柄,创建之后占用内存或显存,用完必须显式释放。这一点和 Python 的垃圾回收习惯不太一样——工具箱对象不参与 Python 的引用计数回收,忘了删就是实打实的内存泄漏。
# 创建一块内部存储,把 numpy 数组内容拷进去 sino_id = astra.data2d.create('-sino', proj_geom, sinogram) rec_id = astra.data2d.create('-vol', vol_geom, 0) # 另一种方式:直接链接,不拷贝 sino_id = astra.data2d.link('-sino', proj_geom, sinogram)create和link的区别值得单独说。link不复制数据,工具箱直接读写你的 numpy 数组,省内存也省拷贝时间,但要求这个数组一直活着且数据类型是单精度浮点。如果数组被回收或者中途被重新赋值,链接就失效了,表现出来是重建结果全是零或者花屏。我一般在批处理脚本里用link,在交互式调试时用create,因为后者更安全,不会因为变量作用域问题出岔子。
释放的写法是统一的:
astra.data2d.delete([sino_id, rec_id]) astra.projector.delete(projector_id) astra.algorithm.delete(alg_id)批量处理几百组数据时,一定要把这三行放进finally块里。我吃过一次亏:循环里某次重建抛出异常,后面所有迭代都在漏显存,跑到第一百组直接崩,前面的结果也因为没有及时落盘全丢了。
2.4 算法家族一览:什么时候用哪个
工具箱里的算法大致分三类:解析类、迭代类、以及单纯的前向/反投影。下面这张表是我实际用下来最常碰到的几个,按用途归类。
| 算法名 | 类型 | 适合场景 | 主要代价 |
|---|---|---|---|
FBP/FBP_CUDA | 解析 | 数据完整、噪声低、要快 | 对噪声和欠采样敏感 |
SIRT/SIRT_CUDA | 迭代 | 数据有噪声、想要稳定收敛 | 收敛慢,需几十到几百轮 |
CGLS/CGLS_CUDA | 迭代 | 想要快收敛、残差最小 | 噪声大时容易过冲出条纹 |
SART/SART_CUDA | 迭代 | 角度少、想做在线更新 | 每轮只用一个角度,慢 |
EM | 迭代 | 数据服从泊松统计、非负 | 收敛极慢 |
FP/BP | 算子 | 只做投影或反投影,不重建 | 需要自己拼流程 |
FDK_CUDA | 解析 | 三维锥束快速重建 | 依赖 GPU,锥角大时退化 |
这张表的读法是:先用FBP_CUDA快速出图,确认几何没错、数据链路通畅;然后换SIRT_CUDA或CGLS_CUDA看能提升多少;如果前两者差距很大,说明数据质量或采样密度有问题,回头查采集环节而不是继续调算法。
3. 完整实操:从仿真数据到一张可对比的重建图
3.1 第一步:造一个能验证结果的仿真体模
任何重建流程都应该先在已知答案的数据上跑通。工具箱自带一个标准体模生成函数,能直接产出经典的测试图案。
import numpy as np import astra N = 256 vol_geom = astra.create_vol_geom(N, N) phantom_id = astra.data2d.create('-vol', vol_geom, 0) # 用内置生成器填充标准体模 astra.creators.create_phantom(phantom_id, type='shepp-logan') phantom = astra.data2d.get(phantom_id)如果没有内置生成器,自己画几个圆也行,重点是包含不同尺寸的高对比度结构——大圆检验低频,小圆检验高频分辨能力,方形边界检验方向性。我通常还会在体模里加一条斜线,因为斜边最能暴露重建中的方向性伪影。
造完体模先别急着往下走,把它的数值范围记下来。后续评估重建质量时,RMSE 必须基于同一个数值范围计算,否则不同实验之间没法比。
3.2 第二步:前向投影,顺便验证一次几何
有了体模和几何,前向投影只需要一行:
proj_geom = astra.create_proj_geom( 'parallel', 1.0, N, np.linspace(0, np.pi, 400, endpoint=False) ) projector_id = astra.create_projector('cuda', proj_geom, vol_geom) # 返回 (数据对象 id, numpy 数组) sino_id, sinogram = astra.create_sino(phantom, projector_id)这一步其实是一个免费的几何校验。前向投影出来的正弦图,如果物体中心不在旋转轴上,那条正弦曲线就会偏离中心位置;如果角度方向反了,曲线的斜率方向就会反过来。我现在的习惯是每次新配几何都先把正弦图存成图片看一眼,两秒的事,能省掉后面排查半小时。
接着加噪,模拟真实采集条件:
# 模拟光子噪声:先转成光子数,加泊松噪声,再转回线积分 I0 = 1e5 intensity = I0 * np.exp(-sinogram) noisy = -np.log(np.random.poisson(intensity) / I0) sino_id_noisy = astra.data2d.create('-sino', proj_geom, noisy)用泊松模型而不是简单的高斯加噪,是因为投影数据的噪声特性本质上由光子计数决定,高衰减区域的噪声方差明显更大。用高斯噪声做实验会低估重建算法在低剂量条件下的困难程度。
3.3 第三步:FBP 快速出图与滤波核选择
解析重建是验证链路的最快路径。
rec_id = astra.data2d.create('-vol', vol_geom, 0) cfg = astra.astra_dict('FBP_CUDA') cfg['ProjectionDataId'] = sino_id_noisy cfg['ReconstructionDataId'] = rec_id cfg['ProjectorId'] = projector_id # 指定滤波核 cfg['FilterType'] = 'hann' alg_id = astra.algorithm.create(cfg) astra.algorithm.run(alg_id) reconstruction = astra.data2d.get(rec_id) astra.algorithm.delete(alg_id) astra.data2d.delete([sino_id, sino_id_noisy, rec_id]) astra.projector.delete(projector_id)滤波核的选择是一条很实用的经验线。ram-lak是理论最优的斜坡滤波器,分辨率最高,但把噪声也一并放大了,低剂量数据上会得到一张满是颗粒的图。hann和hamming在截止频率附近做了平滑,噪声明显降低,代价是边缘变软、细小结构可能糊掉。我的默认策略是:先用hann看一眼整体结构,确认无误后再用ram-lak看细节。如果ram-lak的结果噪声重到看不清,说明该上迭代算法了,而不是继续换滤波核。
提示:滤波核不能替代正则化。它的本质是在频域做一次固定形状的窗函数加权,对噪声的抑制是全局均匀的;而迭代算法的约束和早停是按数据一致性动态调整的,两者作用机制完全不同。
3.4 第四步:迭代重建、约束与收敛监控
迭代重建的代码骨架和解析重建几乎一样,差别在算法名和一批可选项。
cfg = astra.astra_dict('SIRT_CUDA') cfg['ProjectionDataId'] = sino_id_noisy cfg['ReconstructionDataId'] = rec_id cfg['ProjectorId'] = projector_id # 非负约束:绝大多数吸收类成像的物理量不可能为负 cfg['option'] = {'MinConstraint': 0.0, 'MaxConstraint': 1.0} alg_id = astra.algorithm.create(cfg) res_norms = [] for i in range(100): astra.algorithm.run(alg_id, 1) # 残差范数,用来观察收敛趋势 res_norms.append(astra.algorithm.get_res_norm(alg_id))get_res_norm返回的是当前估计与实际投影数据之间的偏差范数,这个指标比肉眼看图可靠得多。正常情况下它应该单调下降然后趋于平缓;如果下降几轮后开始反弹,说明迭代发散了,八成是松弛因子设得太大。我一般会把它画成一条曲线,横轴迭代次数纵轴残差的对数,正常收敛的曲线前段陡降、后段几乎是水平线,拐点位置就是比较合适的停止点。
MinConstraint这个选项看着不起眼,作用却很大。不加约束时,迭代过程中会在物体边缘外圈产生一圈负值,重建图像上表现为暗色晕环;加上非负约束后,这圈东西直接消失了,整体对比度也更接近真值。MaxConstraint则要根据实际物理量的量纲来设,如果投影数据是线积分值,重建结果的量级通常在 0 到 1 之间(归一化后),设成 1.0 是个合理的上界。
SIRT 有一个额外参数是松弛因子,默认值 1.0,实际调的时候在 0.5 到 1.5 之间试。设小了收敛慢但稳,设大了快但容易在噪声大的数据上震荡。我的做法是先用 1.0 跑 50 轮看残差曲线,如果曲线太平就适当加点,如果开始抖就减。
CGLS 是另一条常用路线,收敛轮数通常只要 SIRT 的三分之一到一半,代价是抗噪能力差一些:
cfg = astra.astra_dict('CGLS_CUDA') cfg['ProjectionDataId'] = sino_id_noisy cfg['ReconstructionDataId'] = rec_id cfg['ProjectorId'] = projector_id cfg['option'] = {'MinConstraint': 0.0}实测下来,在信噪比正常的仿真数据上,CGLS 跑 30 轮的残差能追平 SIRT 跑 120 轮,但到高噪声场景,CGLS 第 20 轮之后就开始出现沿角度方向的条纹,SIRT 反而更稳。所以先跑 CGLS 摸底,再用 SIRT 收尾是我比较常用的组合。
3.5 三维锥束:链路一样,坑不一样
三维锥束的代码结构和二维平行束高度相似,主要差异在几何创建和算法名。
vol_geom = astra.create_vol_geom(256, 256, 256) angles = np.linspace(0, 2 * np.pi, 360, endpoint=False) proj_geom = astra.create_proj_geom( 'cone', 1.0, # 探测器像素间距 256, 256, # 探测器行数、列数 angles, 500.0, # 射线源到旋转中心距离 300.0 # 旋转中心到探测器距离 ) projector_id = astra.create_projector('cuda', proj_geom, vol_geom)锥束的角度范围必须是 0 到 2π,这一点和平行束不同,因为锥束每个角度看到的信息都不一样,不存在镜像冗余。两个距离参数的单位要和体素尺寸一致,它们的比值决定了锥角大小。锥角越大,FDK 类重建在远离中心平面的切片上退化越明显,这是解析算法的固有限制,不是参数没调好。
三维的解析重建用FDK_CUDA,迭代重建用SIRT3D_CUDA或CGLS3D_CUDA。后两者对显存的要求相当可观:256³ 的单精度数组是 64 MB,但迭代算法内部同时要持有重建结果、残差、临时缓冲等多份数据,实际占用往往是这个数字的四到六倍。512³ 直接翻八倍,超过 2 GB,普通消费级显卡很容易爆。所以三维场景下我第一件事是算清楚显存预算,而不是先跑起来再说。
4. 参数调优与性能优化:那些文档里不会写的经验
4.1 采样密度怎么定:从公式到实际取舍
采样密度由两个量决定:探测器像素数和投影角度数。探测器像素数一般由设备决定,但在仿真里是可调的;角度数是完全可控的。
先说角度数。前面给过经验公式n_angles ≥ (π/2) × n_det,这个公式假设探测器刚好覆盖物体。如果探测器只覆盖物体的一部分(比如局部成像),角度数的需求会更低;如果探测器覆盖范围远超物体,那么多出来的像素只贡献了零值,纯属浪费计算量。我的实际做法是先按公式取一个值,然后减半跑一次、加倍跑一次,对比三组重建结果的 RMSE,找到曲线的膝点。
再说探测器数。一个常见误区是认为探测器越多越好。物理上确实如此,但计算上不是——探测器数决定了正弦图的宽度,直接线性影响内存和重建时间。我做过一组对比,在同样 400 个角度下,探测器像素从 256 提到 512,重建时间大约翻倍,而实际图像质量的提升在肉眼层面几乎不可见,只有在做定量分析时 RMSE 才下降了不到 10%。这个交换在大多数场景下不划算。
还有一个容易被忽略的点:探测器覆盖范围应该略大于物体直径,通常取物体直径的 1.2 到 1.4 倍。刚好覆盖的话,物体最边缘的投影值会落在探测器边界上,反投影时边界处理会引入一圈伪影。
4.2 迭代参数的三条硬经验
第一条,约束比迭代次数重要。很多新手纠结跑 50 轮还是 100 轮,其实先把非负约束、上界约束加上,50 轮的效果能超过没约束的 200 轮。原因很直白:无约束的迭代空间里有大量物理上不可能的负值解,算法会跑到这些地方去,白白浪费迭代预算。
第二条,早停是一种正则化。迭代算法的本质是逐步拟合数据,跑到最后一定会把噪声也拟合进去。所以最优的停止点往往不是残差最小的地方,而是残差曲线进入平台期之前的那个位置。判断方法是:每隔十轮存一张图,肉眼观察什么时候开始出现颗粒感,上一张图对应的轮数就是合适的停止点。
第三条,不同算法的最优轮数不能混用。SART 是按角度顺序更新的,一轮迭代只用一个角度,所以它的“轮数”概念和 SIRT 完全不同;SIRT 跑 100 轮大概等于 SART 跑好几千轮。切换算法时一定要重新定这个参数,不能照搬。
4.3 显存与速度:一次真实的调优记录
拿一个 512×512 的重建任务举例,几何是 800 个角度的平行束。先用单精度浮点算一下基础开销:重建体积 512×512×4 字节 = 1 MB,正弦图 800×512×4 字节 = 1.6 MB,都不大。但迭代算法内部要把整个投影算子的中间结果也放进去,实际峰值占用可能在几十 MB 量级,这个反而还好。真正吃显存的是三维场景。
调优的手段按效果排序,我实际用下来是这样:
| 手段 | 提速幅度 | 代价 | 适用场景 |
|---|---|---|---|
| 换 GPU 投影器 | 10 倍以上 | 需要可用后端 | 几乎总是首选 |
| 减少角度数 | 线性提升 | 图像质量下降 | 时间敏感、质量要求宽松 |
| 降低迭代轮数 | 线性提升 | 收敛不足 | 有早停策略时 |
| 缩小重建体积 | 平方级提升 | 分辨率下降 | 初步验证阶段 |
| 数据分块处理 | 缓解显存压力 | 代码复杂度上升 | 三维大体积 |
第一行是性价比最高的,基本没有代价。后面几行都要在质量上做交换,所以在跑大批量实验之前,我会先用一组小数据扫一遍参数空间,确定哪些参数是真正敏感的,只对敏感参数做调优,其他的固定住。这个“先摸底再批量”的流程能省掉大量算力。
提示:多进程跑批处理时,每个进程都会独立占用一份显存。如果显存不大,进程数不要按 CPU 核数开,要按“显存总量 / 单任务峰值占用”来算,否则会在一半的地方集体失败。
5. 踩坑记录与问题排查速查
5.1 重建图上出现放射状条带,先查三件事
条状伪影是最常见的症状,成因有几种,排查要按成本从低到高来。第一查角度方向是否一致:如果前向投影用的是 0 到 π 递增的角度,重建时用了递减,得到的不是完全错乱而是某种旋转对称的糊,很容易误判。第二查角度数是否够:按前面那个公式算一下,如果少了三分之一以上,条带就是欠采样的直接表现,这种情况下再怎么调算法也压不下去。第三查是否加了滤波窗:ram-lak在噪声数据上会把条带放大,换成hann立刻能看出区别。这三步走完还没定位,才需要考虑是不是数据本身的问题。
5.2 图像出现重影和“双边缘”,十有八九是旋转中心偏了
真实的扫描系统里,旋转轴很少严丝合缝地穿过探测器中心,几毫米的偏移在重建图上会表现为边缘重影,或者小尺寸结构被“复制”成两个。判断方法很直接:重建一个已知位置的细丝或者小球,如果它的边缘出现对称的双线,基本可以确认。
处理办法有两种。欠费方式是在几何层面直接做后对齐:
# 在后处理阶段微调几何,补偿旋转中心偏移 proj_geom_corrected = astra.geom_postalignment(proj_geom, offset)offset的单位是探测器像素,正值和负值分别对应两个方向。实际操作时用二分法:先试 ±1 像素,看哪个方向变好,再在这个方向上以 0.1 为步长细化。评估指标用重建图像的锐度或者已知结构的边缘宽度,不要靠肉眼,肉眼分辨率不够。
另一种办法是把这个偏移量当参数,做一次小范围的网格搜索,每次重建后算一个锐度指标,取最大值对应的偏移。这个方法更笨但更可靠,缺点是耗时,所以我一般只在固定的设备配置下做一次,把结果写进配置里长期复用。
5.3 典型报错与排查速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 重建结果为全零 | 数据对象链接失效、数组被回收 | 改用create替代link,或确保数组常驻 |
| 报错提示几何维度不匹配 | 2D 与 3D 对象混用 | 检查data2d/data3d是否统一 |
| 显存不足 | 三维体积或角度数过大 | 降低分辨率、减少角度、改分块 |
| 滤波类算法报投影器类型错误 | 算法对投影器有额外要求 | 换用对应的 GPU 投影器类型 |
| 迭代后图像有一圈暗晕 | 缺少非负约束 | 加MinConstraint: 0.0 |
| 结果能出图但方向整体旋转 | 角度范围或方向设反 | 核对角度数组的起点与方向 |
| 批处理跑到一半崩溃 | 显存或内存泄漏 | 把释放语句放进finally块 |
这张表里的第一行我遇到过不止一次。现象特别迷惑——图能出,但是一片黑,也不报错。后来发现是link的数组在一个函数里创建、出了函数就被回收,工具箱还在往那片已经失效的内存里写。从那之后我给自己定了规矩:跨函数传递一律用create,只有在同一个作用域内、生命周期明确时才用link。
5.4 一个容易被忽略的坑:数据类型
工具箱内部统一用单精度浮点,而 numpy 的默认浮点类型是双精度。用link的时候如果传进去的是双精度数组,行为会变得不可预期——有时候是全零,有时候是精度丢失导致的奇怪条纹。这个问题的隐蔽之处在于它不报错,只是结果不对。
# 显式转换,避免类型不匹配 sino = np.ascontiguousarray(sino, dtype=np.float32) vol = np.ascontiguousarray(vol, dtype=np.float32)ascontiguousarray这一层还顺便解决了内存布局的问题——工具箱要求数据在内存里连续排列,而 numpy 做切片、转置之后得到的数组往往是不连续的。两个问题一次解决,代价可以忽略,我现在写这类代码基本是无脑加上。
5.5 想接外部优化器时怎么用
工具箱提供的算子接口可以把投影和反投影包装成线性算子,直接喂给外部的求解器:
import astra W = astra.OpTomo(projector_id) # 前向:W * x 把体积投影成正弦图 # 反向:W.T * y 把正弦图反投影回体积 b = W * phantom.flatten() x = W.T * sinogram.flatten()这个接口的真正价值是让你可以在外部框架里加自定义的正则项、自定义的停止准则,甚至换成完全不同的优化范式,而不用重新实现投影算子。我用它接过一次带全变分约束的求解,省掉了自己写共轭梯度投影算子的功夫,但要注意包装成算子之后,每次乘法的开销和直接调用算法是同一量级,不要指望有额外加速。
最后分享一个小技巧:所有实验都记录三样东西——几何参数字典、算法配置字典、随机种子。前两样用json.dump存下来,几十行代码的事。我后来复盘半年前的一组实验,靠的就是当时存下的这两份字典,不然连角度数是多少都还原不出来。至于随机种子,只要流程里有一处用了随机噪声就必须固定,否则两次跑出来的指标差异根本分不清是算法变了还是噪声变了。