简介:这份 Volume_Raytracer-master 源码包面向计算机图形学与医学图像处理方向的学习者和研究者,核心是用光线追踪算法从三维体数据生成 DRR(数字重建射线照片),帮助理解 X 光成像模拟、光线与体素相交测试、能量衰减计算等关键环节,适合具备一定 C++ 与图形学基础的中高级读者。包内共 96 个文件,以 22 个 C++ 头文件与 22 个 cpp 源文件为主体,配合 12 张 png 示例图像、8 个 pvm 体数据文件、6 个 frag 与 6 个 vert 着色器、2 个 Makefile 及若干 data 配置和 o 目标文件,压缩包约 5.32MB,涵盖光线发射、几何求交、GPU 着色到 DRR 输出各模块。目前已有 329 人学习。借助完整源码、体数据样例与构建脚本,读者可对照实现体绘制管线,掌握着色器编写、体数据加载与 X 光衰减模拟思路,并以此为模板开展医学影像可视化实验。
1. DRR 生成为什么要靠光线追踪:从 CT 体数据到射线投影
放疗计划或者术中导航里有一个很常见的需求:把术前 CT 的体数据转成一张和真实 X 光片视角一致的数字重建射线照片(DRR),用来和透视图像做配准。DRR 的关键不是渲染表面,而是模拟 X 射线穿过人体时被组织吸收、衰减的过程,最后在平面上累积成灰度投影。传统 OpenGL 光栅化管线在这里基本束手无策,因为它只能画三角形构成的表面几何,无法表达「体素密度沿射线方向累加」这种体积效应。光线追踪算法的核心恰恰是在这条路径上:从相机出发发射光线,在体数据内部采样,沿深度方向累积能量衰减。这个 Volume_Raytracer 工程包恰好把这条链路完整打通了,里面同时包含 C++ 源码、GLSL 着色器、PVM 格式的体数据(Bucky、Hydrogen、Tomato)以及交互窗口和输出脚本,能直接编译观察结果,也适合做二次开发。
我第一次拆这个包的时候,发现它不是一个 demo 级别的玩具。项目里 trace.frag 和 trace.vert 负责核心的光线步进,grid.cpp 与 volume.cpp 负责体数据读取和采样,arcballWindow.cpp 提供鼠标拖拽的观察视角,Makefile 把这一切串起来,而 genpvm.cpp 说明还有一条从原始数据生成 PVM 的预处理路径。对做医学图像处理或者体渲染的人来说,这套代码的实际价值不只是「跑通一个效果」,而是让你看到射线追踪、体积光学属性和 DRR 投影三者是如何在同一个 GPU 着色器里协调工作的。下面直接从数据格式讲起,因为所有后续的采样和着色都建立在体数据怎么被加载、怎么被解释的细节之上。
2. 体数据管线的第一步:PVM 格式解析与三线性采样
2.1 PVM 不是图像,是一个密度场容器
PVM 文件在体渲染工具链里经常出现,它的定位和 DICOM 不一样:DICOM 是医院里影像设备输出的带标签格式,包含病人信息、扫描参数和像素数据;PVM 则是经过预处理的纯体数据容器,把一堆 CT 值或者仿真结果序列化成一个可以直接塞进 GPU 的块。Volume_Raytracer 里面的 Hydrogen.pvm、Bucky.pvm、Tomato.pvm 都属于这一类:本质是一个头信息加上一段连续的体素数组。
了解工程内数据流的第一个入口,是先看看 PVM 头是怎么解析的。常见实现会先读 4 个魔法字节PVM2,再读一行 ASCII 头信息来拿到三个维度,然后跳过分隔符直接定位到体素数据区。下面是最常见的解析逻辑:
bool loadPVM(const char *path, int dims[3], uint8_t **voxels) { std::ifstream f(path, std::ios::binary); if (!f) return false; char magic[5] = {0}; f.read(magic, 4); // 读取 "PVM2" 版本标记 f.ignore(1); // 跳过版本号后的空白 char header[256] = {0}; f.getline(header, 256); // 首行 ASCII 头:维度、体素类型等 sscanf(header, "%d %d %d", dims, dims + 1, dims + 2); size_t voxelCount = (size_t)dims[0] * dims[1] * dims[2]; *voxels = new uint8_t[voxelCount]; f.read((char*)(*voxels), voxelCount); // 剩余数据连续读入 return true; }这里用getline而不是固定偏移读头,是为了兼容不同 PVM 生成器的头部宽度差异。sscanf取前三个整数作为三个轴的体素数量,和很多体渲染教程里硬编码 64³ 的做法相比,这个解析方式能自适应任意分辨率。项目里的genpvm.cpp应该就是做反向过程的,把 RAW 或者仿真结果打包成这种带简单头的格式,方便体渲染主程序直接加载。
2.2 采样为什么用三线性插值:从离散网格到连续密度场
光靠体素索引访问数据是远远不够的。当光线在体数据内部步进时,采样点几乎不可能刚好落在整数体素坐标上,如果直接用最近的体素值代替,渲染出来的 DRR 会充满块状走样,尤其当视角旋转时那种噪声会非常明显。三线性插值是最基本的解决办法,先在 x 方向做两次线性插值,y 方向再一次,z 方向最后一次:
float trilinear(const float *data, int dims[3], float x, float y, float z) { int x0 = (int)x, y0 = (int)y, z0 = (int)z; int x1 = std::min(x0 + 1, dims[0] - 1); int y1 = std::min(y0 + 1, dims[1] - 1); int z1 = std::min(z0 + 1, dims[2] - 1); float fx = x - x0, fy = y - y0, fz = z - z0; float c000 = data[(z0*dims[1]+y0)*dims[0]+x0]; float c100 = data[(z0*dims[1]+y0)*dims[0]+x1]; float c010 = data[(z0*dims[1]+y1)*dims[0]+x0]; float c110 = data[(z0*dims[1]+y1)*dims[0]+x1]; float c001 = data[(z1*dims[1]+y0)*dims[0]+x0]; float c101 = data[(z1*dims[1]+y0)*dims[0]+x1]; float c011 = data[(z1*dims[1]+y1)*dims[0]+x0]; float c111 = data[(z1*dims[1]+y1)*dims[0]+x1]; float c00 = c000*(1-fx)+c100*fx; float c10 = c010*(1-fx)+c110*fx; float c01 = c001*(1-fx)+c101*fx; float c11 = c011*(1-fx)+c111*fx; float c0 = c00*(1-fy)+c10*fy; float c1 = c01*(1-fy)+c11*fy; return c0*(1-fz)+c1*fz; }插值的边界策略对 DRR 结果影响很大。如果采样坐标越过边界,用 clamp 把坐标固定在边界体素上,会让高密度物体边缘产生明显的密度拖尾;如果采样点已经离开体数据区域,正确的做法是把密度当作 0,也就是空气无衰减,这样投影的轮廓才会干净。很多工程直接调 GPU 纹理的寻址模式,把GL_CLAMP_TO_BORDER配合透明边界来用,效率比在着色器里做条件判断高得多。
2.3 体数据上 GPU:3D 纹理与内部格式
解析完 PVM 之后,数据要从 CPU 内存搬到 GPU 显存,体渲染里这一步通过 3D 纹理完成。3D 纹理的优势是 GPU 硬件自带三线性滤波和归一化坐标,省掉自己写插值的麻烦。数据位宽决定了纹理内部格式的选择,具体映射关系如下:
| 体素类型 | CPU 数据类型 | OpenGL internal format | 说明 |
|---|---|---|---|
| 8 位灰度 | uint8_t | GL_R8 或 GL_RED | CT 值压缩,最常见 |
| 16 位灰度 | uint16_t | GL_R16 | 动态范围大,医学数据常见 |
| 32 位浮点 | float | GL_R32F | 仿真结果,精度优先 |
| RGBA 传输前预计算 | uint8_t | GL_RGBA8 | 传输函数 bake 进纹理 |
上传代码通常是这样:
glGenTextures(1, &tex3D); glBindTexture(GL_TEXTURE_3D, tex3D); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE); glTexImage3D(GL_TEXTURE_3D, 0, GL_R8, dims[0], dims[1], dims[2], 0, GL_RED, GL_UNSIGNED_BYTE, voxels);这里的GL_LINEAR让硬件完成三线性插值,GL_CLAMP_TO_EDGE保证边缘采样不会出现环绕伪影。有一点需要留意:如果直接拿 16 位 CT 数据做 DRR 而不做窗宽窗位映射,那低密度软组织和高密度骨骼会挤在一个极窄的灰度区间里,投影图看起来几乎全黑或全白。常见做法是先在 CPU 端做一个线性映射或者窗位截断,把关心密度范围扩展到 0 到 1,再上传纹理,这样后面的传输函数调整才有区分度。
3. 从相机到采样点:包围盒求交与光线步进
3.1 slab method:体包围盒相交测试的正确姿势
体数据是一个长方体区域,光线要进入这个区域才需要采样。用解析几何的 slab method 求射线和包围盒的最近/最远交点,一次性拿到采样区间,比一步步试探进入点高效得多。核心思想是把三维求交拆成三个轴向上的一维区间求交再取交集。
bool intersectBox(vec3 rayOrigin, vec3 rayDir, vec3 boxMin, vec3 boxMax, out float tNear, out float tFar) { vec3 invDir = 1.0 / rayDir; vec3 t1 = (boxMin - rayOrigin) * invDir; vec3 t2 = (boxMax - rayOrigin) * invDir; vec3 tMin = min(t1, t2); vec3 tMax = max(t1, t2); tNear = max(max(tMin.x, tMin.y), tMin.z); tFar = min(min(tMax.x, tMax.y), tMax.z); return tFar > max(tNear, 0.0); }这里的invDir是预计算的方向倒数,避免每个轴重复做除法。t1和t2分别表示射线进入和离开该轴 slab 的位置,取三个轴的交叠区间后,tNear是光线真正进入体数据的起点,tFar是穿出的终点。注意最后判断用了max(tNear, 0.0),因为相机如果在包围盒内部时tNear会是负数,此时真正起点应该是 0,这个细节在做体绘制相机飞行时非常关键,漏掉会导致视点在体积内时画面直接消失。
3.2 GPU 步进循环:步长、早期终止与提前出局
拿到[tNear, tFar]后,剩下的工作是在区间内均匀步进采样。片段着色器里要对每条射线循环执行:计算当前三维位置,从体纹理采样密度,应用传输函数得到颜色和不透明度,然后按从前往后合成。一个简化但结构完整的片段着色器核心循环如下:
uniform sampler3D volumeTex; uniform vec3 camPos; uniform mat4 invViewProj; uniform float stepSize; vec4 rayMarch(vec3 ro, vec3 rd) { vec3 boxMin = vec3(0.0); vec3 boxMax = vec3(1.0); float t0, t1; if (!intersectBox(ro, rd, boxMin, boxMax, t0, t1)) return vec4(0.0); vec3 pos = ro + rd * (t0 + stepSize * 0.5); float t = t0; vec4 accum = vec4(0.0); float remaining = 1.0; for (int i = 0; i < 512 && t < t1; i++) { float density = texture(volumeTex, pos).r; vec4 color = transferFunction(density); // 密度 → RGBA accum.rgb += color.rgb * color.a * remaining; remaining *= 1.0 - color.a; if (remaining < 0.02) break; // 几乎不透明,提前终止 pos += rd * stepSize; t += stepSize; } return vec4(accum.rgb, 1.0 - remaining); }步长stepSize直接决定采样质量和性能:太大导致图像穿帮,高密度薄片会被直接跳过去;太小则循环次数爆炸。常见做法是让步长等于体数据最长边除以目标采样次数,比如 256 分辨率对应 0.0039 的步长(归一化坐标),512 步的循环上限通常够用。提前终止阈值remaining < 0.02是性能关键,对不透明材质能省掉接近一半的无效采样。注意 DRR 和这个循环的差别:DRR 不需要 RGBA 合成,它需要的是沿路径累积密度并做指数衰减,两者公式完全不同,如果你直接把 alpha 合成当作 DRR 输出,得到的只是体渲染风格的投影而不是射线的物理衰减图。
3.3 起点抖动:消除条带、稳定轮廓
GPU 步进的固定步长有一个经典问题:当视线几乎平行体数据切片时,相邻像素走过的采样位置高度相似,投影图上会出现规则的波纹条带。解决办法是抖动采样起点,把每个像素的初始偏移量在[0, stepSize]内随机化,打断这种空间相关性。实现只需要一行改动:
float jitter = hash(gl_FragCoord.xy) * stepSize; vec3 pos = ro + rd * (t0 + jitter);代价是图像会引入一档均匀的颗粒噪声,尤其当步长偏大时噪声明显。对 DRR 场景,这个噪声通常可以接受,因为 X 光片本身就有量子噪声,后续做高斯滤波或者各向异性滤波都能压下去。与之相对,不要为了消除条纹而无限制减小步长,因为步长减半意味着循环次数翻倍,在 4K 分辨率下 512 步的循环已经让 GPU 压力很大了,更合理的方式是先加抖动再配合中等步长。
4. 传输函数、梯度光照与折射:让体数据看起来像物质
4.1 传输函数放 GPU 还是 CPU:哪种方式更适合工程
三维体数据的数值本身没有颜色信息,必须靠传输函数把密度值映射成 RGBA。实现在 GPU 还是 CPU 上,工程上的取舍很不一样:
| 实现位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| CPU 预计算为 1D 纹理 | 逻辑简单,可视化调试方便 | 修改参数需要重新上传纹理 | 线上项目、传给 shader 查表 |
| 着色器内函数计算 | 实时调参无延迟 | 参数多时代码难维护 | 调颜色、测试窗宽窗位 |
| GPU 内生成 1D 纹理 | 动态生成效率高 | 实现复杂,边界情况多 | 需要随帧变化的传输函数 |
我自己一般选择第一种,把传输函数做成 256 个条目的 1D 纹理,因为可以在 CPU 侧一边修改密度到颜色的映射,一边在窗口里实时看到调色板。项目里的HydrogenDensity.png这类文件,从命名看就是传输函数预览图或者物质密度分布图,它提醒你传输函数本身是可以脱离主程序单独调试的。无论是查表还是函数计算,传输函数都应当支持窗宽窗位概念:CT 值范围往往跨越上千 HU,而感兴趣的组织可能只集中在几百 HU 区间,先做窗位截断再做颜色映射,DRR 的对比度才会正常。
4.2 梯度法线与 Blinn-Phong 光照:体绘制的立体感来源
不加光照的体绘制只是半透明颜色堆叠,看起来像雾。真实立体感来自体素梯度——把密度变化方向当作表面法线,再套 Blinn-Phong 反射模型。梯度可以用中心差分近似,在归一化坐标下:
vec3 computeGradient(vec3 p) { float eps = 0.002; float d0 = texture(volumeTex, p + vec3(eps, 0.0, 0.0)).r - texture(volumeTex, p - vec3(eps, 0.0, 0.0)).r; float d1 = texture(volumeTex, p + vec3(0.0, eps, 0.0)).r - texture(volumeTex, p - vec3(0.0, eps, 0.0)).r; float d2 = texture(volumeTex, p + vec3(0.0, 0.0, eps)).r - texture(volumeTex, p - vec3(0.0, 0.0, eps)).r; vec3 n = -vec3(d0, d1, d2); float len = length(n); if (len > 1e-5) return n / len; return vec3(0.0); }注意这里法线方向取负梯度,因为密度从高到低的下降方向才是物体朝外的方向。eps不能设得太大,否则梯度会跨过细小结构,让薄的骨皮质和高密度植入物的边界糊掉;一般取半个到两个体素的归一化宽度比较合适。梯度亮度加在累积颜色上时,用color.rgb * (ambient + diffuse * max(dot(n, lightDir), 0.0))就够,不需要太复杂的反射模型,毕竟体绘制的视觉重心在于半透明穿透感,而不是镜面高光。
4.3 折射近似:从 Lens.pvm 看透光体渲染的工程玩法
包内出现了Lens.pvm和HydrogenDensity-refraction.png,说明这套实现里不只有简单的体绘制,还探索了折射效果。半透明体渲染的折射思路是:光线进入高密度介质时发生方向偏折,按 Snell 定律计算新的透射方向,然后沿着新方向继续步进一段距离再采样。GLSL 可以直接调用内置函数求折射方向:
vec3 refractDir = refract(rd, gradientNormal, eta); vec3 refrPos = pos + refractDir * 0.05; // 偏移一段距离 float refrDensity = texture(volumeTex, refrPos).r; color.rgb = mix(color.rgb, vec3(refrDensity), 0.3);这里的eta是两种介质的折射率比,介质密度越高折射率越大,边界处可以看到明显的扭曲。折射对 DRR 的意义有那么一点微妙:真实 X 射线的折射极小,医学 DRR 几乎不会考虑它;但如果你想把透视效果做得更接近光学镜头,或者需要在教育演示中表达密度差异导致的路径偏折,这个模块就是很好的参考实现。与 DRR 一致的地方在于,二者都强调「射线穿过物质」而不是「光线碰到表面就结束」,这也是体渲染和表面渲染最根本的区别。
5. 编译运行与交互视角:Makefile、arcball 和 PNG 输出
5.1 从源码到可执行文件:Makefile 怎么把各个模块串起来
工程根目录下的 Makefile 扮演总调度角色。源码分布在主目录和src/子目录下,核心模块包括window.cpp(窗口与 OpenGL 上下文)、gpuProgram.cpp(GLSL 着色器编译与链接)、grid.cpp(体数据网格)、volume.cpp(体绘制循环接口)、arcballWindow.cpp(交互视口)、linalg.cpp(矩阵向量运算)、cube.cpp(体包围盒)以及genpvm.cpp这个独立的数据转换工具,构建流程大致长这样:
CXX = g++ CXXFLAGS = -std=c++11 -O2 -Wall LDFLAGS = -lglut -lGL -lGLEW -lfreeimage OBJS = main.o window.o arcballWindow.o grid.o volume.o \ cube.o linalg.o gpuProgram.o font.o volren: $(OBJS) $(CXX) -o $@ $(OBJS) $(LDFLAGS) main.o: main.cpp headers.h $(CXX) $(CXXFLAGS) -c main.cpp volWindow.o: volWindow.cpp volWindow.h $(CXX) $(CXXFLAGS) -c volWindow.cpp genpvm: genpvm.o $(CXX) -o $@ genpvm.o $(LDFLAGS) clean: rm -f *.o volren genpvm-O2是体渲染工程最低限度的优化选项,千万不能去掉,否则片段着色器的循环矩阵运算在 CPU 端的预处理和参数计算会慢到影响交互。-lfreeimage用于 PNG 输出,如果系统没装这个库,可以把输出模块替换成stb_image_write.h这种只有头文件的实现。.o文件散落在列表里并不需要手工编译,make 会依据依赖关系自动处理。编译失败多数是两个原因:一是缺 GLUT/GLEW 开发包,Ubuntu 下需要apt install freeglut3-dev libglew-dev libfreeimage-dev;二是 64 位系统找不到 32 位库路径,检查 LDFLAGS 里是否遗漏-L/usr/lib/x86_64-linux-gnu。
5.2 arcball 交互:鼠标拖拽背后的四元数旋转变换
观察体数据时,用户把鼠标从屏幕左上拖到右下,期望看到的不是图像平移,而是整个三维数据集绕视点中心旋转。这就是 arcball 窗口存在的价值。实现思想是把鼠标位置投影到一个虚拟球面上,拖拽前后两个投影点确定一根旋转轴,旋转角度由两向量夹角决定。工程里常见的处理方式是用四元数累积旋转:
// mouse down 时记录起始球面坐标 startVec = mapToSphere(mouseX, mouseY); // mouse motion 时计算当前球面坐标,并更新旋转四元数 curVec = mapToSphere(mouseX, mouseY); Quat delta = Quat::rotationBetween(startVec, curVec); rotation = delta * rotation; startVec = curVec;mapToSphere把窗口坐标映射到单位球体,落在球外的点要投影到球边缘,否则旋转手感会很奇怪。rotation四元数累积到模型视图矩阵后,乘上体包围盒的 8 个顶点的顶点坐标,再传到着色器作为射线的方向构造来源。好处是视角改变时不需要重新上传体数据,只需要更新invViewProj矩阵,DRR 的投影方向也随之改变,这对术前规划里模拟不同机架角度非常有价值。
5.3 输出 PNG:glReadPixels 与 viewParameters.data 的配合
程序运行到某个视角时,需要把当前帧保存成 PNG 用于离线分析。常见流程是从默认帧缓冲读像素,再交给 FreeImage 写文件。viewParameters.data这个文件记录了当前相机的外参矩阵和视角参数,复现实验时可以直接读回来,保证两次运行看到同一张 DRR:
void saveFrame(const char *filename, int w, int h) { std::vector<uint8_t> pixels(w * h * 3); glPixelStorei(GL_PACK_ALIGNMENT, 1); glReadBuffer(GL_FRONT); glReadPixels(0, 0, w, h, GL_RGB, GL_UNSIGNED_BYTE, pixels.data()); FIBITMAP *bmp = FreeImage_ConvertFromRawBits( pixels.data(), w, h, w * 3, 24, 0xFF0000, 0x00FF00, 0x0000FF); FreeImage_Save(FIF_PNG, bmp, filename, PNG_DEFAULT); FreeImage_Unload(bmp); }有一点必须注意:OpenGL 的glReadPixels坐标原点在左下角,保存的图像是上下颠倒的,写文件前要垂直翻转,否则生成的 DRR 看起来就像术前计划里镜像翻转的解剖结构。翻转操作可以放在 CPU 端遍历像素行交换,也可以用glPixelZoom配合二次渲染解决。判断视角是否一致的快速办法是把viewParameters.data里的投影矩阵和当前帧矩阵做差,差矩阵的迹接近 0 时基本可以认为视角重合。
6. 把 alpha 合成改成衰减积分:一个 DRR 模式的完整改造
如果你拿这个渲染器直接输出 DRR,会发现结果更像彩色体绘制投影,因为默认路径做的是颜色累积。要得到符合 X 射线物理的 DRR,需要把传输函数的意义从「颜色 + 不透明度」换成「线性衰减系数」,再把合成算法从 front-to-back alpha blending 换成指数衰减积分。简化后的一张 DRR 图,每个像素对应的物理模型是:
I(x, y) = I₀ · exp(-∫ μ(l) dl)
其中 μ 是体素对 X 射线的线性衰减系数,积分沿视线方向穿过整个体数据。离散化后,可以把整条射线上每个采样点的 μ 乘以步长再累加,最后取指数。改造后的衰减积分着色器核心部分如下:
uniform sampler3D volumeTex; uniform float stepSize; uniform float muScale; // 密度值到线性衰减系数的标定系数 uniform int mode; // 0: 体绘制; 1: DRR void main() { vec3 ro, rd; // ... 从相机参数构造光线 ro, rd ... float t0, t1; if (!intersectBox(ro, rd, vec3(0.0), vec3(1.0), t0, t1)) discard; vec3 pos = ro + rd * (t0 + stepSize * 0.5); float opticalDepth = 0.0; float t = t0; for (int i = 0; i < 1024 && t < t1; i++) { float density = texture(volumeTex, pos).r; float mu = density * muScale; // 密度→衰减系数 opticalDepth += mu * stepSize; // 离散化积分 pos += rd * stepSize; t += stepSize; } float I = exp(-opticalDepth); // Beer-Lambert 定律 gl_FragColor = vec4(vec3(I), 1.0); }muScale是一个需要标定的系数,它把体素密度归一化值映射到物理衰减系数。如果原始数据是 CT 值,更严谨的做法是先做 CT 值到线性衰减系数的查表,但工程里用线性缩放系数通常也能获得形态学上正确的 DRR。关键点在于第一个乘法:density * muScale之后的值应该远小于 1,否则步长累计后指数会过度衰减,图像直接变成全黑;一般让单步的mu * stepSize在 0.01 到 0.05 量级比较合适。这个着色器里我保留了mode字段,方便跑体绘制和 DRR 的对比实验,实际做配准研究时这个双模式输出非常有用。
验证 DRR 是否正确,有两个低成本的自测方法。第一个是取两个密度不同的均匀 PVM 数据,比如一个全是 0.2 的密度、另一个全是 0.4 的密度,同样条件下渲染出的投影值应当呈指数衰减关系:第二个的灰度值约等于第一个的平方(在无噪声的理想状态下)。如果不是,优先检查步长和muScale的乘积是否稳定。第二个是旋转对称性测试:把视角绕体数据中心旋转 90 度,球对称或圆柱对称的合成数据应该得到灰度分布一致的投影,如果出现边缘亮度不对称,说明相机矩阵或包围盒求交里有坐标轴翻转。日常调试时我会开一个实时灰度探针窗口,用鼠标点 DRR 图像上的像素,回现实测灰度值和理论计算的光学厚度,这比肉眼看图判断灰度偏差要可靠得多。最终调参时记住一个原则:DRR 的噪声和步长是妥协关系,步长加密到 1024 次采样后,优先用抖动而不是继续加密度,因为抖动带来的高频噪声可以用 3×3 中值滤波干净地清除掉,而步长不足造成的结构缺失是滤波补不回来的。
本文还有配套的精品资源,点击获取