
1. 从一张切片图像说起Radon变换到底在算什么如果你做过CT重建大概率对Radon变换又爱又恨。网上讲解Radon变换的资料非常多但大部分都停留在数学定义式的层面——给你一个 ( p(s,\theta) \int\int f(x,y)\delta(x\cos\thetay\sin\theta-s)\mathrm{d}x\mathrm{d}y ) 的公式然后拍一张某个角度的投影图就结束了。我第一次真正需要动手写CT正向投影代码时这个公式远远不够用。为什么因为公式描述的是连续域上的理想投影而现实中你的CT扫描仪采集到的是一组离散的探测器单元读数。你需要回答一个更具体的问题给定一张二维连续的密度分布图也就是断层图像怎么在计算机里精确地模拟出探测器在不同角度收到的投影数据这个问题在CT成像领域叫做正向投影forward projection它既是CT重建反向投影、滤波反投影、迭代重建的反过程也是理解Radon变换最直观的抓手。你甚至可以把正向投影当成一台虚拟CT机喂给它一张断层图像它给你吐出一组投影数据——这组数据在临床CT里就是探测器接收到的射线强度对数变换值在学术界通常叫sinogram正弦图。要想真正搞懂Radon变换我强烈建议先从物理过程出发而不是直接啃公式。一台CT扫描仪的典型成像过程是X射线球管发出扇形射线束穿透被测物体对面的探测器阵列记录衰减后的强度。当X射线穿过物体时沿着每一条射线路径物质都会吸收一部分能量探测器接收到的强度与路径上的衰减系数积分直接相关。这个沿着一条线做积分的过程就是Radon变换的核心物理原型。所以你可以用一句人话概括Radon变换Radon变换就是把二维图像沿着不同方向的直线做线积分把二维信息压缩成一组一维信号。每条直线由两个参数决定法线方向角度 ( \theta ) 和到原点的距离 ( s )。旋转 ( \theta )遍历所有距离 ( s )你就得到了一张以 ( (s, \theta) ) 为坐标的二维图——这张图就是CT重建算法里要处理的投影数据也就是正弦图。我第一次做完反向的滤波反投影重建后又回头去实现正向投影最大的体会是正向投影比反向重建更容易写出看似正确、实则完全错误的代码。因为反向重建的输出是图像你一看就知道伪影长什么样正向投影的输出是数字你很难凭肉眼判断这些数字到底对不对。但如果你要开发迭代重建算法或者做CT系统仿真正向投影的正确性就直接决定了整个仿真管线的可靠性。所以我打算在这篇文章里把Radon变换离散化实现的所有细节、C和Matlab两套完整代码、以及我排过的那些坑全部写清楚。2. 离散化Radon变换把连续公式变成能跑的代码很多教材里的Radon变换公式长这样[ p(s,\theta)\int_{-\infty}^{\infty}\int_{-\infty}^{\infty} f(x,y)\ \delta(x\cos\theta y\sin\theta - s)\ \mathrm{d}x\mathrm{d}y ]这个式子的精妙之处在于 ( \delta ) 函数。( \delta(x\cos\theta y\sin\theta - s) ) 在直线上才不为零。注意这里的 ( x\cos\theta y\sin\theta s ) 正是直线法线式方程。也就是说固定一个角度 ( \theta )过原点作一条法线法线方向就是 ( (\cos\theta, \sin\theta) )而距离原点 ( s ) 处有一条与之垂直的直线。这条直线上所有像素值的总和严格来说是线积分就是投影值 ( p(s,\theta) )。这里有一个特别容易搞混的点Radon变换里的 ( \theta ) 通常指的是射线法线方向与x轴的夹角而不是射线本身的方向。平行束扫描时如果球管从正上方照射射线方向竖直向下此时对应的 ( \theta 0 )旋转90度后从左方照射射线方向水平向右此时对应的 ( \theta \pi/2 )。这个约定直接影响到你的坐标变换写错的时候有没有90度的相位偏移。2.1 为什么不能直接按公式暴力积分你真的把公式直接变成双重循环去算绝大多数图像的像素位置并不会恰好落在那一条直线 ( s x\cos\theta y\sin\theta ) 上。CT图像由离散像素组成——一个像素是一块小方块表示该区域内物质的平均衰减系数。一条射线斜着穿过这个像素时穿过的路径长度、射线离像素中心的距离都不同所以简单判断像素中心是否在直线上会带来极大的误差。更糟糕的是暴力双重循环的时间复杂度是 ( O(N \times M \times S \times A) )——其中 ( N、M ) 是图像宽高( S ) 是投影位置数( A ) 是角度数。如果是512×512的图像做180个角度、每个角度729个探测器通道运算量非常惊人而且里面的判断逻辑几乎全部浪费。解决思路很直接不要让像素去找射线而让射线去扫图像。对每条射线即每个 ( \theta, s ) 组合沿着它的路径在图像上采样、插值、累加。这条路径可能经过多个像素通过相邻像素的值插值估算射线在该点的衰减贡献。这本质上是计算机图形学里最经典的画线问题——只是我们把屏幕上画的那条线换成了CT射线路径上的积分。2.2 核心步骤拆解一次完整的正向投影代码上分四步走初始化正弦图矩阵。行数 投影角度数每个角度一行列数 探测器通道数每个通道对应一个 ( s ) 采样所有值初始化为0。对每个投影角度 ( \theta )确定旋转方向。将探测器方向 ( u ) 和射线方向 ( v ) 定义出来构成旋转坐标系的基向量。这一步本质上是坐标系的旋转( u (-\sin\theta, \cos\theta) ) 是探测器方向( v (\cos\theta, \sin\theta) ) 是射线方向。对每条射线从图像一侧进入、另一侧穿出。用参数方程 ( R(t) \text{center} u \cdot s v \cdot t ) 表示射线路径( t ) 是沿射线方向的步进参数。在图像边界内部以固定步长 ( dt ) 采样若干点每个采样点通过双线性插值求出图像灰度值累加到当前投影值里。乘以一个尺度因子。这里有一个很容易被忽略的细节离散化后采样是等间隔的你其实在计算 ( \sum f(x_i) \Delta t )只是一个黎曼和。所以最后必须乘以 ( \Delta t ) 才是真正的积分值近似。上面第三步的从图像一侧进入、另一侧穿出在代码里做起来稍微麻烦一些因为射线与图像矩形边界的交点需要算。不过如果用沿射线方向遍历足够大的范围边界外的采样点灰度值取0的做法也可以——代价是大量采样点落在图像外的零值区域上造成浪费。我比较推荐先算出射线与图像的交集范围只在有效区间里采样。2.3 采样间距与精度的关系有个参数需要特别留意沿着射线的采样步长 ( dt )。如果太大射线会跳过细小的结构投影数据出现明显的锯齿和欠采样伪影如果太小计算量爆炸但精度提升有限。结合我自己的实测经验步长取像素尺寸的1/4到1/2是一个比较甜点的区间。像素尺寸是1.0在图像坐标系下单位长度通常代表一个像素那么步长取0.25~0.5既不会让射线跳过像素间的细微结构也不至于让单条射线的采样点多到影响计算速度。为什么步长1/4~1/2就够核心原因是后面的双线性插值本身已经做了相邻像素混合的处理。你以0.5的步长采样每两个采样点之间的信息变化早就被插值平滑过了再细化下去边际收益递减。我做过分辨率测试从步长0.5降到0.1投影值的变化不到0.5%但单条射线耗时增加了5倍完全得不偿失。2.4 关于离散雷登变换和线性雷登变换的差别研究生的教材里还有离散Radon变换Discrete Radon Transform, DRT和线性Radon变换等变体。DRT用有限群论方法设计在离散格点上精确满足Radon变换性质数学性质优雅对整数格点图像能保证映射的离散正交性但实现复杂对任意尺寸、任意角度的支持不好。而我们要做的CT正向投影学术上属于线性Radon变换的数值离散版本——相对灵活支持任意角度、任意探测器布局、任意图像尺寸。CT仿真领域用的基本都是这类方法因为你需要模拟任意扫描几何平行束、扇束、锥束而不是被数学结构的采样网格限制住。这篇文章只讨论后者。3. C实现详解从零搭一个平行束正向投影器选C来实现一是因为真实CT设备里的重建引擎基本全是C/C二是因为正向投影在迭代重建算法里要被调用几十上百次性能敏感C的优化空间大得多。下面给出一个自包含的平行束正向投影实现。我用的是OpenCV读取和存储图像但你完全可以只用标准库加自定义的二维数组类——核心逻辑不依赖OpenCV。3.1 接口设计和数据结构// 正向投影器接口 // param img: 输入二维图像浮点型假设像素尺寸为1.0 // param anglesRad: 投影角度弧度例如linspace(0, pi, 180) // param numDetectors: 探测器通道数每个投影角度的采样点数 // return 正弦图矩阵维度为 (angles * numDetectors)行角度列投影位置 std::vectorfloat forwardProject( const std::vectorfloat img, int width, int height, const std::vectorfloat anglesRad, int numDetectors);其中正弦图的存储方式我选的是按行优先行编号对应角度序列编号对应探测器通道序。这个数据布局是重建算法里通用的规范化格式因为后续做滤波反投影FBP时滤波操作就是对每一行的投影数据进行FFT按行连续存储在缓存友好性上最优。3.2 核心函数实现#include vector #include cmath #include algorithm #include cstdint // 双线性插值在(x, y)处采样图像值越界返回0 static inline float sampleImage( const std::vectorfloat img, int width, int height, float x, float y) { if (x 0.0f || y 0.0f || x (height - 1) || y (width - 1)) return 0.0f; // 在图像范围外按0处理 int x0 static_castint(x); int y0 static_castint(y); int x1 std::min(x0 1, height - 1); int y1 std::min(y0 1, width - 1); float fx x - x0; float fy y - y0; float v00 img[x0 * width y0]; float v01 img[x0 * width y1]; float v10 img[x1 * width y0]; float v11 img[x1 * width y1]; return (v00 * (1 - fx) v10 * fx) * (1 - fy) (v01 * (1 - fx) v11 * fx) * fy; } // 计算一条射线与图像矩形边界的交点区间[t0, t1] static void rayIntersect( float cx, float cy, // 旋转中心一般为图像中心 float s, // 探测器偏移距离射线到中心法线的距离 float ux, float uy, // 探测器方向单位向量 float vx, float vy, // 射线方向单位向量 int width, int height, float t0, float t1) { // 图像边界为 [0, width] x [0, height] 的矩形 // 射线参数方程P(t) C u*s v*t float ox cx ux * s; float oy cy uy * s; t0 -1e9f; t1 1e9f; // 依次夹取四个半平面 if (fabs(vx) 1e-12f) { float inv 1.0f / vx; float ta (0.0f - ox) * inv; float tb (width - ox) * inv; if (ta tb) std::swap(ta, tb); t0 std::max(t0, ta); t1 std::min(t1, tb); } else { if (ox 0.0f || ox width) { t0 1.0f; t1 0.0f; } } if (fabs(vy) 1e-12f) { float inv 1.0f / vy; float tc (0.0f - oy) * inv; float td (height - oy) * inv; if (tc td) std::swap(tc, td); t0 std::max(t0, tc); t1 std::min(t1, td); } else { if (oy 0.0f || oy height) { t0 1.0f; t1 0.0f; } } } // 正向投影主函数 std::vectorfloat forwardProject( const std::vectorfloat img, int width, int height, const std::vectorfloat anglesRad, int numDetectors) { const int numAngles static_castint(anglesRad.size()); std::vectorfloat sinogram(numAngles * numDetectors, 0.0f); // 探测器覆盖范围以图像中心为原点 // 覆盖半径需要保证任何角度的射线都能完整覆盖图像 float radius std::sqrt(width * width height * height) * 0.5f; // 旋转中心为图像中心像素坐标为浮点 float cx width * 0.5f; float cy height * 0.5f; // 采样步长像素尺寸的1/4到1/2之间这里取0.5 const float dt 0.5f; for (int ia 0; ia numAngles; ia) { float theta anglesRad[ia]; float cosT std::cos(theta); float sinT std::sin(theta); // 探测器方向笛卡尔坐标下单位向量 // 注意这里u (-sinθ, cosθ)v (cosθ, sinθ) // 不要粗心写成(cosθ, sinθ)和(-sinθ, cosθ)——投影方向与坐标系的约定必须自洽 float ux -sinT; float uy cosT; // 射线方向从射线源指向探测器或者反过来取决于你的遍历习惯 float vx cosT; float vy sinT; for (int id 0; id numDetectors; id) { // 探测器位置s的范围-radius 到 radius float s -radius (float(id) 0.5f) * (2.0f * radius / float(numDetectors)); float t0, t1; rayIntersect(cx, cy, s, ux, uy, vx, vy, width, height, t0, t1); if (t1 t0) continue; // 射线不穿过图像 float sum 0.0f; // 沿射线逐步采样 for (float t t0; t t1; t dt) { float px cx ux * s vx * t; float py cy uy * s vy * t; sum sampleImage(img, width, height, px, py); } // 乘上采样步长将求和转化为积分近似 sinogram[ia * numDetectors id] sum * dt; } } return sinogram; }3.3 这份代码的几个关键设计选择探测器覆盖半径为什么要取对角线的半个长度因为当 ( \theta 45^\circ )或者任意斜角时图像矩形的对角线方向恰好正对探测器平面矩形的最远角到旋转中心的距离就是半对角线。如果你贪图方便取图像宽度的一半当半径你会发现斜角投影时图像角落的像素被削掉了重建出来四个角全是伪影而且这种伪影极其隐蔽——一眼看不出但RMSE就是降不下去。为什么采样步长 ( dt ) 取0.5一个像素的尺寸归一化为1.0那在一条长度 ( L ) 的射线上采样点数大约是 ( 2L ) 个。双线性插值本身有平滑效果这个密度已经能捕捉绝大多数结构。如果你处理的是高分辨率图像且对SNR要求极高可以把 ( dt ) 缩到0.25载入时间大约翻倍但如果你处理的是噪声数据0.5足够了。rayIntersect函数里边界条件为什么要单独处理fabs(vx) 1e-12射线方向几乎与x轴平行时除以接近零的小数会导致灾难性的数值放大甚至产生NaN。单独把这个情况分出去判断起点是否在边界之间是数值稳健性必须做的。3.4 关于像素坐标系中心必须提醒的一个坑代码里的旋转中心 ( (cx, cy) (width \times 0.5, height \times 0.5) )这是像素坐标系的浮点中心。但OpenCV里读入图像存储为 ( (h, w) )第一个下标是行方向y方向第二个下标是列方向x方向。写代码时如果你先把width、height的顺序搞反则投影会整体翻转90度。我在第一版代码里就吃过这个亏正弦图看起来形状没问题但反投影重建出来的图像相当于把原图转了90°再镜像。排查了半天发现是把cx width * 0.5写成了cx height * 0.5。这类维度混淆错误在C里编译器根本不会警告务必在调试时先拿一个非对称图像比如一个左上角有亮块的图做测试。3.5 性能优化思路上面这份代码是教学版本重在逻辑清晰。如果要用在迭代重建里每次迭代都要做一次正向投影加一次反向投影性能还需要进一步优化角度预计算每个角度的 ( \cos\theta、\sin\theta )、方向向量、每条射线的起点和步进增量全部提前算好存下来不要在主循环里重复cosf、sinf。去除divt dt的循环换成整数索引 增量比较减少浮点累加误差。并行化外层按角度并行OpenMP每个角度内部的探测器循环没有数据依赖天然适合并行。但要注意多线程的写冲突只发生在不同行之间所以每个线程处理一个角度行就行。我的实测结果是4核并行加速比能到3.4倍左右。查表插值sampleImage里的地址计算和边界判断优化空间也很大可以把插值权重和地址预先算好存到数组中代价是内存占用增大。4. Matlab实现更直观的Radon变换与自定义对比Matlab自带radon函数不过它具体是怎么做的很少有人讲。这一节我先给出一个自定义的M文件版本逻辑和C一致然后把两者的输出做对比接着用矩阵化和图像处理工具箱的方法写一个简洁版最后验证你写的正向投影是否正确。4.1 自定义函数与C逻辑等价用M文件重写一遍Radon正向投影核心逻辑和上一节C完全相同。用Matlab可以省去内存管理的麻烦尤其适合先做学术验证。function sinogram myRadon(img, anglesDeg, numDetectors) % MYRADON 自定义Radon变换平行束正向投影 % 输入 % img 二维灰度图像double类型假定像素尺寸1 % anglesDeg 投影角度向量度 % numDetectors 探测器通道数 % 输出 % sinogram size: numel(anglesDeg) x numDetectors [H, W] size(img); numAngles numel(anglesDeg); radius sqrt(W^2 H^2) / 2; cx W / 2; cy H / 2; dt 0.5; sinogram zeros(numAngles, numDetectors); for ia 1:numAngles theta deg2rad(anglesDeg(ia)); cosT cos(theta); sinT sin(theta); % 探测器方向单位向量 ux -sinT; uy cosT; % 射线方向单位向量 vx cosT; vy sinT; for id 1:numDetectors s -radius (id - 0.5) * (2*radius / numDetectors); % 射线与矩形边界求交得到t范围 ox cx ux * s; oy cy uy * s; t0 -inf; t1 inf; if abs(vx) 1e-12 ta (0 - ox) / vx; tb (W - ox) / vx; t0 max(t0, min(ta, tb)); t1 min(t1, max(ta, tb)); elseif ox 0 || ox W t0 inf; t1 -inf; end if abs(vy) 1e-12 tc (0 - oy) / vy; td (H - oy) / vy; t0 max(t0, min(tc, td)); t1 min(t1, max(tc, td)); elseif oy 0 || oy H t0 inf; t1 -inf; end if t1 t0 continue; end t t0:dt:t1; X cx ux*s vx*t; Y cy uy*s vy*t; vals interp2(img, X, Y, linear, 0); sinogram(ia, id) sum(vals, omitnan) * dt; end end endinterp2在坐标上支持向量化计算一次性采样整条射线上的所有点然后求和。注意interp2默认要求 X 对应第二维列方向/宽度Y 对应第一维行方向/高度我用X对应宽度坐标、Y对应高度坐标。添加omitnan是因为双线性插值在极个别位置可能产生 NaN 值虽然上面的t0:t1已经把射线限制在图像区域以内但边界交点落在像素边界上的舍入问题确实偶尔会触发奇异的坐标值。4.2 对比Matlab自带radon函数Matlab的图像处理工具箱里有radon函数但它的输出和我们自定义版本有两个细微差别需要注意坐标定义不同Matlab 的radon返回的第一列对应投影方向角度的法线方向与 X 轴成theta度。也就是说radon(img, 0)是沿竖直方向做射线投影。和我们myRadon的定义在当前角度下应该一致但radon函数内部默认把旋转中心放在floor((size(img)1)/2)而不是精确的浮点中心size(img)/2。对于偶数尺寸的图像如512×512这两个位置相差0.5个像素反映到正弦图上会有微小偏移。默认探测器数不同radon内部自动计算探测器数量大约ceil(sqrt(W^2H^2))加一些填充如果你要对比应该显式提供探测器数例如[R, xp] radon(img, 0:179, 512);然后和myRadon的输出放在一起对比理想情况下两条正弦图的差异应该只有亚像素级别的数值噪声。如果差异显示出明显的条纹或错位说明你自己的坐标定义和Matlab内置约定有偏差。我自己的测试结果用512×512的Shepp-Logan phantom180个角度512个探测器通道myRadon和内置radon的峰值绝对误差不到1e-3图像像素值范围0~1归一化均方根误差(NRMSE)大约在0.1%量级。这个差距主要来自探测器网格离散化和interp2与内置实现内部插值方式的不同算法细节。4.3 向量化加速版矩阵求逆思路与矢量化操作上面的循环版本在Matlab里跑得不是很快180个角度 × 512个探测器 × 约700个采样点总计超过6千万次插值调用虽然已经用了矢量化但循环框架还是慢。有一个更Matlab风格的加速思路把所有角度的坐标变换一次性张量化用interp2在三维网格上插值。function sinogram myRadonVectorized(img, anglesDeg, numDetectors) % 向量化版本适合原型验证内存开销大 [H, W] size(img); radius sqrt(W^2 H^2) / 2; cx W / 2; cy H / 2; dt 0.5; s linspace(-radius, radius, numDetectors); % 1 x D theta deg2rad(anglesDeg); % 1 x A % 生成网格 [Theta, S] meshgrid(theta, s); % D x A Ux -sin(Theta); Uy cos(Theta); % 探测器方向 Vx cos(Theta); Vy sin(Theta); % 射线方向 % 计算射线与图像的t范围这一步矢量化的写法和循环版本一样麻烦省略细节 % 这里直接用固定范围遍历 [T, Sg] meshgrid(0:dt:radius, S); % 近似处理 X cx Ux.*Sg Vx.*T; Y cy Uy.*Sg Vy.*T; vals interp2(img, X, Y, linear, 0); sinogram squeeze(sum(vals, 2)) * dt; end需要说明的是这个向量化版本忽略了对每一条射线单独计算[t0, t1]的步骤——固定从0遍历到radius所以射线覆盖范围比实际图像要大。优点是代码极短、向量化彻底缺点是为了保证覆盖范围做了不必要的计算内存占用也大。对于小图像快速验证足够实际仿真还是用前面的逐射线版本更精准。初学者建议先把两个版本的结果对比一下确认差异在合理范围内再选择速度更快的方案。4.4 用自定义Matlab版验证C实现当你要确认C代码和Matlab代码是否等价时标准做法是生成同一张测试图Phantom分别用两套代码算正弦图然后计算逐像素的差值。我在开发C版本时用了一个很有效的验证流程在Matlab里生成Shepp-Logan体模图phantom(Modified Shepp-Logan, 256)保存为.pgm文件。在C里读取这张图用C版本的forwardProject生成正弦图保存成.pgm文件。在Matlab里读取C生成的正弦图和Matlab版本的输出直接做差。观察差值图的均方根误差和最大值。这一套流程下来如果你发现差值图和正弦图本身的结构长得一样误差不是噪声而是有清晰的结构那几乎肯定是采样步长或边界处理逻辑不一致如果误差是全局均匀的小值则可能是浮点精度差异可以放心继续用。4.5 一个非常常见的坑角度单位混淆Matlab里radon默认接受角度为度而C代码里我用的是弧度。我在第一次跨语言对比时忘记把度数转成弧度结果C算出来的正弦图和Matlab的差得非常离谱——因为角度单位差了几十倍投影方向完全不同。这种单位混用的事故写代码时加个硬性检查非常值得// C侧入口处强制要求弧度如果数值范围看起来像度数就报警 for (float a : anglesRad) { if (a 3.15 a 3.17) { // 约等于pi大概率是弧度合法 } else if (a 179 a 181) { // 约等于180看起来像度直接抛出异常 throw std::runtime_error(anglesRad看起来是度数请先转换为弧度); } }Matlab侧同理deg2rad要写在函数入口不要在项目代码的不同位置到处转换转换逻辑散落各处是bug的温床。5. 正向投影的正确性检查肉眼无法判断时的三个自检手段我反复强调正向投影输出的数字看起来像那么回事儿不等于正确。这节给出三个我在实战中经常用的自检方法每一步都能定位到具体问题。5.1 方法一点光源响应测试也叫逐点检验拿一张背景全黑、正中央只有一个亮度为1的像素的图像对它做Radon变换。理论上任意角度下只有一个探测器通道对应s0的那条射线应该收到一个正投影值其他通道全是0。而且这个值的大小应当等于像素亮度 ( \times ) 射线穿过该像素的路径长度。更精确地说由于双线性插值和射线的离散采样这个单像素在正弦图上也会呈现一个模糊——但模糊的扩散半径应该非常小约1个探测器通道宽度。如果你发现点光源的投影扩散到了多个通道或所有通道都有值说明你的探测器方向/射线方向定义有错或者采样步长太大。我用这个方法抓到一个经典错误第一次写角度旋转时我把ux -sinT; uy cosT写成了ux cosT; uy sinT。点光源的正弦图出现了以s0为中心的正弦曲线扩散而不是集中在中间一行——这正好暴露了探测器方向和射线方向搞反了的问题。你还可以同时测两个点一个在中心一个在图像边框附近比如坐标(5, 5)。边缘那个点的投影值应当在某些角度变成0射线恰好不穿过它这个边界效应也是一项重要的正确性判据。5.2 方法二解析几何闭合式验证对一幅由简单几何形状组成的图像可以解析计算出Radon变换的闭合式结果用于对比数值实现。例如一个半径为 ( R )、密度为 ( \rho ) 的均匀圆盘圆心在原点其在角度 ( \theta )、位置 ( s ) 处的投影值为[ p(s,\theta) \begin{cases} 2\rho\sqrt{R^2 - s^2}, |s| \le R \ 0, |s| R \end{cases} ]注意这个结果与 ( \theta ) 无关圆盘各向同性。用这个公式测你的实现生成一个半径为20像素、中心在原点的圆盘图像数值投影结果应该和解析式吻合误差范围在1%以内。第二个经典闭合式验证是直线型物体一条宽度为 ( w )、密度为 ( \rho ) 的无限长均匀直线带方向与y轴平行即x坐标在 ( [-w/2, w/2] ) 之间。当投影角度为0°射线水平方向时投影值应为 ( \rho w )常数当投影角度为90°时投影值应是0射线平行于直线带。这个直观测试对新手特别友好能帮你验证角度定义是否跟物理直觉一致。5.3 方法三反向投影恢复一致性检验这是最实用的一招把正向投影结果送进滤波反投影重建算法FBP重建出来的图像应该和原图几乎一样——前提是你的FBP实现没bug。如果正向投影有错重建结果必然出现系统性伪影。如果你同时拥有iradon或自己实现了FBP做这个端到端测试的性价比非常高。我自己调试的顺序通常是先跑点光源测试 → 再跑圆盘解析式 → 最后跑FBP端到端。前两步定位问题快几步就能定位到某个具体坐标变换写错最后一步验证整体管线的数值精度。另外如果身边没有FBP代码可以用Radon变换的平移性质做一个替代测试把图像整体平移 ( (dx, dy) )那么正弦图应该满足[ p_{\text{shifted}}(s, \theta) p_{\text{orig}}(s - (dx\cos\theta dy\sin\theta), \theta) ]也就是说平移只改变s坐标不改变投影值的大小。用这个性质检查你的实现可以排除双线性插值和坐标旋转写反的情况。5.4 方法四额外数据守恒性检验在平行束几何下所有角度所有通道的投影值总和与原图像总灰度像素值之和应当存在固定比例关系。严格推导下投影值之和 ( \sum p(s,\theta) ) 与图像总灰度 ( \sum f(x,y) ) 的比值等于投影角度的数量除以采样步长相关的一个常数。这个比例关系不依赖图像内容。具体来说我用一张512×512、像素和为 ( T ) 的图像做180个角度、探测器间距等于像素尺寸的投影然后计算ratio sum(sinogram(:)) / sum(img(:)); % 不会恰好等于常数这个比值在不同图像间应该保持稳定波动0.1%。如果比值随图像内容变化说明插值或归一化处理有问题——这种测试特别适合自动化回归测试中作为冒烟测试的一环。6. 从平行束到扇束正向投影在真实CT里的变形前面所有代码处理的是平行束CT——射线彼此平行、垂直入射探测器。现实中临床CT基本都用扇束或锥束几何但理解平行束的正向投影是一切的基础。这一节快速讲清楚平行束代码如何扩展到扇束以及为什么要先掌握平行束。6.1 扇束CT的几何描述扇束CT的射线源是一个点发出的射线形成一个扇形穿过物体后被对面的弧形或平面探测器接收。每条射线不再由 ( (s, \theta) ) 确定而由两个不同参数决定射线源所在的角度 ( \beta )球管旋转角和该射线与扇束中轴线的夹角 ( \gamma )扇角。做扇束正向投影时每一条射线需要用两个角度参数去计算它和图像的交点然后同样做插值采样累加。虽然几何变了但沿直线做积分的本质没有变。核心代码和上面的平行束版本共享sampleImage、rayIntersect这些基础能力只需要换一套坐标映射关系。具体来说设射线源在角度 ( \beta ) 处的位置为 ( S(\beta) (R\sin\beta, -R\cos\beta) )R为源到旋转中心的距离扫描几何里这个距离叫源到中心距离通常远大于图像尺寸。扇束中第 ( k ) 条射线对应的扇角为 ( \gamma_k )那么这条射线的方向就是与中心射线成 ( \gamma_k ) 角度的方向。用两点式射线源点和射线方向的单位向量构建参数方程然后与图像矩形求交、采样、累加流程和平行束完全一样。6.2 为什么建议先搞懂平行束学习路径上我却建议每个新手都从平行束入手。原因有三平行束是所有重建理论的基础。滤波反投影算法、中心切片定理、Radon逆变换公式全都是在平行束假设下推导出来的。先把平行束正向投影写对你对投影是什么的直觉就建立起来了。平行束的解析闭合式更容易验证。前面给的圆盘公式、点光源检验在平行束几何下都是精确成立的扇束几何下这些解析解会复杂得多。重排技术可以建立联系。扇束数据可以通过重排rebinning转换成近似的平行束数据这也是许多CT设备预处理的第一步。理解了平行束投影的生成方式你才知道重排的时候哪些数据点可以互相映射、哪些会有插值误差。6.3 迭代重建中为什么需要反复调用正向投影最后聊聊正向投影在迭代重建里的地位。CT迭代重建如OS-SART、OSEM的核心循环可以这样概括根据当前估计的图像 ( f^{(k)} )用正向投影计算预测的投影数据 ( p^{(k)} A f^{(k)} )。把真实测量投影 ( p_{\text{meas}} ) 和预测投影 ( p^{(k)} ) 做差得到残差 ( \Delta p )。把残差反向投影回图像域得到修正量 ( \Delta f )。更新 ( f^{(k1)} f^{(k)} \lambda \Delta f )重复直到收敛。其中第1步每次迭代都要做一次完整的正向投影第3步要做一次反向投影Radon变换的对偶算子。一个512³的锥束CT迭代重建可能要迭代50~100次每次都需要生成数十万条射线的投影值——这就是为什么正向投影的性能优化如此重要也是为什么业界常用GPU或专用重建硬件加速的原因。当然这已经超出本文的讨论范围了。我建议先把平行束正向投影的各种细节彻底吃透理解了投影数据是怎么生成、怎么验证的后面再接触扇束/锥束几何、再碰迭代重建你会发现自己对投影这个词的理解比只做重建不碰正向的人深得多。7. 踩坑实录实现Radon正向投影时我犯过的五个错误这一节是纯经验分享。以下五个错误前两个我在教材和博客里没见过有人明确指出过但它们造成的后果却非常隐蔽。7.1 旋转中心取整导致的半像素伪影早期写Matlab实现时我图方便直接把旋转中心写成cx ceil(W/2)因为看很多老代码都这么写。在偶数尺寸图像上真正的几何中心应该在W/2 256.0而ceil(512/2)也是256——看起来一样但对尺寸257的图呢中心就在128.5取整成129就偏差了0.5个像素。这个半像素偏差的影响是投影数据里每个角度都有0.5像素的平移偏差重建出来的图像会有轻微的双边模糊和环形伪影。最坑的是这个伪影在高频结构边缘、骨骼边界上特别明显在均匀区域完全看不出来你很容易误以为是噪声或者滤波核的问题。正确做法旋转中心永远是浮点数即W/2和H/2而不是(W1)/2、(W-1)/2或任意取整。7.2 把射线方向和探测器方向在90度附近的初始化搞混代码里ux -sin(theta), uy cos(theta)探测器方向和vx cos(theta), vy sin(theta)射线方向这两行我在不同框架里来回抄过好几次非常容易搞混。一个直观的判断方法令 ( \theta 0 )那么探测方向应该是什么按我们的定义( \theta 0 ) 时应该是射线从正上方竖直向下照平行束探测器是水平的所以探测器方向应该在x轴上——此时ux -sin(0) 0, uy cos(0) 1探测器方向在y轴正方向上。这看起来和水平矛盾这里要理解一点在图像坐标系里y轴正方向是向下的图像的行方向。当射线方向vx cos(0) 1, vy sin(0) 0射线方向沿x轴正方向——这是水平方向的射线束探测器方向沿y轴——也是竖直排列的探测器。这个约定和图像坐标系下常见的θ0是水平方向的直觉不同CT成像里的θ0指的是探测器平面的法线方向沿x轴也就是说射线是从左边水平向右照射的。如果你用日常的角度0就是水平线直觉去看就会认为ux和vx应该写反。在我的代码约定里θ0时射线沿水平方向从左侧打到右侧而探测器沿竖直方向排列——这个约定和许多教科书是一致的。但另外一些代码库可能采用不同的约定。关键不是哪个约定正确而是你在一整条管线的每个环节正向、反向、坐标变换、重建都用同一个约定。7.3 采样步长过大导致的高频细节丢失前面说过步长取0.5是合理的但如果你处理的图像里包含极细的线状结构比如血管造影图或者模拟图像里的细线目标0.5的步长可能无法正确采样这些亚像素级结构——射线在这些结构上的采样点太少积分值被低估。解决办法对这类图像步长要降到0.25以下或者在采样点之间做更精细的重建。不过这个性能代价不小。我在做血管模拟时步长从0.5改到0.25后RMSE下降了约35%但耗时增加了整整一倍。权衡后我选择了步长0.35——性能损失1.4倍误差下降25%。7.4 忽略了探测器通道的像素间距与图像像素尺寸的匹配探测器通道数量的选择必须保证探测器间距不超过图像像素尺寸的一半奈奎斯特条件否则斜角投影时会出现混叠。具体来说探测器覆盖半径是 ( R )通道数是 ( D )那么探测器间距是 ( 2R/D )。如果这个间距大于图像像素尺寸一个探测器通道对应了图像上的多个像素被压缩在一起失去了分辨能力。我建议至少保证 ( D \ge \sqrt{W^2H^2} )也就是探测器间距不超过1.0个像素尺寸。实际使用里我一般取1.5倍于对角线的长度因为这样可以稍微留点余量避免边缘伪影。如果你在正弦图上看到明显的锯齿——不是图像本身的边缘而是非连续跳变——多半就是探测器间距过大导致的混叠。我之前偷懒把探测器数从512减到256重建出来的图像立刻出现了规则的横向条纹。7.5 写文件时使用整数截断导致投影值消失这个坑更像工程问题而非算法问题。当你把正弦图保存为8位PGM时投影值直接从浮点数变成了0~255的整数。如果原始投影值范围是0~700因为一条射线可能穿过很多个高密度像素直接保存会截断到255以下所有值都在255处饱和正弦图一片白。正确做法是保存32位浮点格式或16位PNG或者做归一化后再转为8位。Matlab里的imwrite对浮点图像默认归一化到[0,1]还算安全但C里手动写文件时非常容易踩这个坑。我做跨语言对比时发现C版正弦图整体比Matlab版大50多倍就是因为Matlab的imwrite自动归一化而C直接按原始值写出去了。提示永远不要在中间步骤里把浮点投影数据转成8位。转一次就永久丢失精度后面所有分析都在错误的数据上进行。哪怕只是为了快速可视化也建议存成16位或32位格式再降采样显示。8. 各参数实测对比与推荐配置写了这么多代码和坑最终还是要回到一个实际问题实际项目里参数该怎么定我整理了一个从仿真精度和计算开销角度实测过的参数配置表供参考。参数推荐值实测误差表现备注采样步长 dt0.25 ~ 0.5像素0.25相对0.5误差降低约25%但耗时翻倍含细线结构取0.25一般结构取0.5探测器通道数≥ 对角线像素数 × 1.5低于对角线长时出现明显混叠伪影512×512图建议至少728通道我用768投影角度数180~360平行束小于180时重建误差以角度采样为主角度越多sinogram越大但重建质量改善有限插值方式双线性最近邻误差是本方法的3~8倍更高阶双三次改善微弱但耗时大幅增加探测器间距≤ 像素尺寸大于像素尺寸时边缘分辨率下降和通道数联动旋转中心W/2, H/2浮点半像素偏差导致系统边缘伪影偶数尺寸图像尤其注意关于角度数多说一句180°范围在平行束下已经足够覆盖所有信息——因为 ( p(s, \theta\pi) p(-s, \theta) )后180°基本是前180°的镜像重复。我做360°投影时正、反方向的投影值差异只来自插值不对称带来的数值噪声小于0.01%。临床CT常用1000~2000个角度的原因是扇束几何下角度采样密度和探测器排数需要匹配平行束仿真用180~360就够了。9. 从正向投影到完整CT仿真管线如果你已经顺利实现了正向投影那接下来的方向就非常开阔了正向投影是整个CT仿真系统的基石几乎所有和CT成像相关的算法验证都需要先有能精确生成投影数据的模块。9.1 用正向投影生成训练数据深度学习方向现在做低剂量CT图像增强、CT重建的深度学习研究最大的痛点是缺数据——真实CT扫描有辐射伦理问题公开数据集标注不统一。而用正向投影加仿真噪声可以无限生成带各种噪声伪影的训练样本。具体做法准备一批高分辨率CT图像作为真实断层。用正向投影得到干净正弦图。给正弦图加泊松噪声模拟低剂量条件下的光子统计噪声、电子噪声、散射噪声。用FBP或迭代重建把带噪正弦图重建为含伪影图像。形成伪影图像—真实图像的训练对。这套流程里正向投影的正确性直接决定了生成数据的质量上限。我在Deep learning低剂量CT增强实验里就是这么干的用我们自己实现的正向投影器生成仿真数据配合公开数据微调效果比只用公开数据训练提升了约10%的PSNR。这套流程里正向投影的正确性直接决定了生成数据的质量上限——如果你的投影在斜角上歪了几个像素深度学习模型会学到这种歪斜的伪特征反而更糟。9.2 用正向投影检验重建算法的极限反向验证同样重要。当你实现了一个重建算法FBP、ART、SART、SIRT等需要知道它在完美投影数据下能达到的最优表现是什么——这就是用正向投影生成绝不含噪声的投影再送入重建算法。这样你可以把重建误差分解为两部分算法本身的逼近误差以及噪声带来的扰动误差。没有正向投影器你永远无法单独评估算法误差。我测试过SART迭代重建在我正向投影器下的表现迭代到第50次之后误差几乎不再下降说明已经收敛到了正向投影算子定义的系统极限。此时如果误差还很大应该检查重建算法本身而不是怀疑投影数据。这个分析方法在调试重建算法时不知道帮我省了多少时间。9.3 把方法推广到3D锥束CT的FDK重建二维平行束只是起点。真实CT都是三维锥束扫描。FDK算法是最经典的锥束近似重建算法核心思路就是对投影数据的每一行/列分别做加权滤波反投影。虽然直接用我们的二维正向投影器无法生成真正的锥束投影但理解Radon正变换之后再看锥束几何的坐标变换理解成本会大幅下降。如果你打算自己写一个简单的锥束CT模拟器可以从多层扇束近似开始把三维物体切成一叠二维切片每一层用扇束正向投影或者说绕z轴旋转的Radon变换生成投影再把所有层的投影堆叠成三维正弦图。这个近似忽略了解剖结构的轴向交叉效应但对算法验证来说足够用。我的经验是先把这套逐层Radon变换管线跑通再去啃真正的cone-beam几何公式脑子会清楚很多。10. 最后再分享一点调试心得我修bug修到快崩溃的时候最后看了一遍这段代码发现调试效率最高的办法是用极简信号穿过系统。不要在调好的系统里用复杂图像去测一定要切到极端简单的输入——单像素点、均匀圆盘、直线条——然后再用解析结果去对照。这比任何日志输出、断点调试都高效。另外给投影数据做可视化的时候一定要看一眼正弦图的坐标轴标签和方向。正弦图横轴是探测器位置s纵轴是角度θ一般显示成从上到下角度递增。如果你发现正弦图里某个高密度目标对应的轨迹看起来是倒着的正弦波不要慌那可能只是显示方向的问题——关键看轨迹的相位是否和目标的物理位置对得上。目标在图像左上角时某个角度范围内的投影值应该出现在探测器的一侧如果你发现投影值出现在相反侧那坐标变换就铁定写反了。最后有一些小习惯也很值得养成命名里带单位anglesRad和anglesDeg分开命名C里统一用弧度Matlab里入口统一转弧度。固定约定并写注释在头文件里写明投影角度θ0时射线方向沿X轴、探测器方向沿Y轴这样哪怕半年后再看代码也不会因为遗忘约定而写错坐标变换。自动化回归测试把点光源测试、圆盘解析式测试的代码存成独立脚本每一次重构后都跑一遍。实测这个习惯能拦住至少三成以上的低级回归错误。CT断层成像的系列写到第三篇我自己也明显感觉到正向投影和Radon变换是CT所有算法逻辑的地基这个地基打牢了后面无论走重建算法、系统仿真还是深度学习方向心里都是有底的。希望这篇代码级踩坑级的拆解能让你少走我走过的那些弯路。