⛳️ 引言
在我们日常开发中,可能需要在一个 Viewport
中显示多个 Volume
,即既要显示一个 CT 片也要显示一个 PET 片,同时可能还要能够调整融合效果中某个 Volume
的透明度、优先显示某个 Volume
、既能修改 CT 的窗宽窗距又要能够修改 PET 的窗宽窗距等等需求,这时就用到融合影像相关的知识点。本文从 CT/PET 融合基础知识、基础融合渲染实现、获取融合后某个点的数据值及修改映射方案等多方面展开。
🌴 背景知识
在实现具体的代码逻辑前,我们先了解一下涉及到的相关概念,便于理解后续的实现(不感兴趣的可以直接跳过该主题)
CT(计算机断层扫描)
-
成像原理:使用 X 射线拍摄身体的横截面图像。CT设备通过旋转X射线管发射射线,穿过身体不同的组织,再由检测器接收。由于不同组织(如骨骼、肌肉、器官)对 X 射线的吸收程度不同,计算机会根据这些差异生成身体内部的详细横截面图像。
-
成像方式:提供解剖结构图像,可以精确显示身体内部的器官、骨骼等结构。
-
应用场景:常用于检查结构异常,如骨折、肿瘤、血栓、内出血等。它能够提供身体内部的精确解剖结构图像,帮助医生了解病灶的大小、形状和位置。
PET(正电子发射断层扫描)
-
成像原理:使用放射性同位素标记的示踪剂(通常是类似葡萄糖的物质),通过静脉注射进入体内。示踪剂会被体内活跃的代谢组织吸收,然后发射正电子。这些正电子与周围的电子相遇,发生湮灭反应,产生两个相对的伽马射线,PET 扫描仪可以检测到这些伽马射线,并生成身体内活跃代谢区域的图像。
-
成像方式:提供功能性图像,显示的是体内代谢活跃的区域,通常与癌症或其他代谢性疾病有关。
-
应用场景:常用于观察身体内部的代谢活动,主要用于癌症的诊断和分期评估。它能够显示肿瘤细胞的活跃程度,以及是否存在癌症转移。此外,PET 也用于研究心脏功能和大脑活动。
PET-CT融合影像
PET 和 CT 可以联合使用,称为 PET-CT。这种方法结合了 CT 的解剖结构图像和 PET 的功能图像,能够提供更全面的诊断信息。例如,在癌症诊断中,PET-CT 可以同时显示肿瘤的位置和活性,为制定治疗计划提供重要依据。
🌾 实现步骤
在了解完背景概念后,我们来实现一个基础的融合效果,在实现前,需要先准备两组数据:
-
一组用于显示CT效果的
DICOM
或nifti
文件(CTImageIds
) -
一组用于显示PET效果的
DICOM
或nifti
文件 (PTImageIds
)
初始化Cornerstone环境
// 初始化 - Dicom文件加载器
initCornerstoneDICOMImageLoader();
// 初始化 - Volume加载器
initVolumeLoader();
// 初始化 - CornerStone
await csRenderInit({
gpuTier: false,
});
// 初始化 - CornerStone/tool
csToolsInit();
创建和配置视图
const renderingEngine = new RenderingEngine('renderingEngineId');
const viewportInputArray = [
{
viewportId: 'viewportId1',
type: csEnums.ViewportType.ORTHOGRAPHIC,
element: document.querySelector("#element1"),
defaultOptions: {
orientation: csEnums.OrientationAxis.AXIAL
}
},
{
viewportId: 'viewportId2',
type: csEnums.ViewportType.ORTHOGRAPHIC,
element: document.querySelector("#element2"),
defaultOptions: {
orientation: csEnums.OrientationAxis.SAGITTAL
}
},
{
viewportId: 'viewportId3',
type: csEnums.ViewportType.ORTHOGRAPHIC,
element: document.querySelector("#element3"),
defaultOptions: {
orientation: csEnums.OrientationAxis.CORONAL
}
}
];
renderingEngine.setViewports(viewportInputArray);
准备影像数据加载
在只渲染CT影像时,我们只初始化了一个Volume
,在渲染融合影像时,需要分别为CT和PET初始化对应的Volume
,并加载它们的Dicom
文件
// CT 影像加载
ctVolume = await volumeLoader.createAndCacheVolume(
‘ctVolumeId’,
{
imageIds: CTImageIds // 准备的CT影像文件
}
);
ctVolume.load();
// PET 影像加载
ptVolume = await volumeLoader.createAndCacheVolume(
‘ptVolumeId’,
{
imageIds: PTImageIds // 准备的PET影像文件
}
);
ptVolume.load();
渲染CT和PET影像
在渲染时,与其他流程中不一致的地方为第二个参数是数组,可以为当前视图添加多个Volume
,即多个Volume
同时作用于一个视图。
await setVolumesForViewports(
renderingEngine,
[
{
volumeId: 'ctVolumeId', // 为视图添加CTVolume
},
{
volumeId: 'ptVolumeId', // 为视图添加PETVolume
}
],
['viewportId1', 'viewportId2', 'viewportId3']
);
// 渲染图像
renderingEngine.renderViewports(['viewportId1', 'viewportId2', 'viewportId3']);
至此,我们就完成了一个融合影像的渲染,通过上面整体流程可以看出来,渲染流程与我们熟悉的是一致的,只是在向Viewport视图中添加Volume时,添加了多个。这里要注意一下‼️‼️:Volume添加的顺序就是显示的顺序,先添加CT,再添加PET,PET就会处于上层,当PET的opacity设置为1后会完全覆盖CT。
渲染效果
点击查看完整代码,欢迎关注Star
🌱 数据处理与获取
在完成影像渲染后,一般我们会需要在鼠标滑过时获取到当前点的CT/PET值,或者获取到当前的一些位置信息,接下来我们来看一下如何进行数据获取。
获取canvas坐标和世界坐标
- 获取canvas坐标数据
当鼠标在影像上滑过时,实时获取到对应的canvas坐标
const element = event.currentTarget;
// 获取canvas元素在视口中的位置信息及大小
const rect = element.getBoundingClientRect();
// 鼠标点的屏幕坐标X - canvas元素距离屏幕左侧的位置信息 = 当前点相对于canvas元素的X坐标
// 鼠标点的屏幕坐标Y - canvas元素距离屏幕上厕的位置信息 = 当前点相对于canvas元素的y坐标
const canvas = [
Math.floor(event.clientX - rect.left),
Math.floor(event.clientY - rect.top)
];
- 获取世界坐标数据
得到canvas坐标后,Cornerstone3D提供了对应的API canvasToWorld,我们可以通过调用API直接将canvas坐标转换为世界坐标
// 获取当前视口
const viewport = getRenderingEngine('renderingEngineId').getViewport('viewportId1');
// 转换坐标系
const worldPos = viewport.canvasToWorld(canvas);
获取CT/PET影像数据
当鼠标滑过时,获取到当前点的CT值或PET值。 关于获取CT值,在之前 Cornerstone3D中获取Dicom文件CT值的实践方案 一文中介绍过大致的流程,获取PET值的方式与之一致, 只是传入的volume参数不一样。
计算代码
function getValue(volume, worldPos) {
const { dimensions, scalarData, imageData } = volume;
// 获取到三维索引
const index = imageData.worldToIndex(worldPos);
index[0] = Math.floor(index[0]);
index[1] = Math.floor(index[1]);
index[2] = Math.floor(index[2]);
if (!csUtils.indexWithinDimensions(index, dimensions)) {
return 'out Range';
}
// 三维索引 到 一维索引的 线性转换的实现方式
const yMultiple = dimensions[0];
const zMultiple = dimensions[0] * dimensions[1];
const value =
scalarData[index[2] * zMultiple + index[1] * yMultiple + index[0]];
return value;
}
线性转换解析
计算流程逻辑如下,接下来针对整体流程再展开详细说一下具体的实现方式 - 三维索引到一维索引的线性转换
。
-
整体流程
-
背景知识【scalarData】
我们需要获取CT/PET值,那首先需要考虑哪个属性是与标量值有关系的 => scalarData
,scalarData
存储的是影像数据中每个像素或体素的具体数值,例如CT值(HU值)、MRI的信号强度、或者PET扫描中的放射性浓度值等。在图像渲染过程中,scalarData
用于生成最终图像的核心数据。它与颜色映射(colorMap
)结合,将数值数据转化为可视化的颜色图像。
scalarData
的数据类型为类型化数组(一维数组)
- 思考逻辑
当了解完 scalarData
后,我们就有了以下的一个思考路径
- 具体实现 - 线性转换
目标是是获取一维索引,我们已知三维坐标,将三维坐标转换为三维索引,对三维索引进行 ✅ 线性转换 得到一维坐标(‼️ 关于三维不同坐标系之间的转换会在后续坐标系专题中展开,这里只针对上述getValue
函数中的线性转换进行说明)
转换为代码实现为
const yMultiple = dimensions[0]; // width
const zMultiple = dimensions[0] * dimensions[1]; // width * hight
const indexAll = index[2] * zMultiple + index[1] * yMultiple + index[0] // 一维坐标值
现在我们既知道标量数据的一维数组 scalarData
,也知道了hover值在数组中的索引 indexAll
,所以就能得到当前标量值为 scalarData[indexAll]
🎨 颜色映射与渲染优化
colorMap(颜色映射)
-
概念: 在影像中对不同的数值或信号强度进行可视化显示的颜色映射方案
-
作用: 将不同强度的信号映射为颜色,以便于放射科医生或研究人员可以更直观地识别影像中的高活性区域和低活性区域
-
常用方案:
-
Hot: 热图,通常从黑色到红色、黄色、白色,表示从低到高的信号强度。
-
Gray: 灰度图,从黑到白的渐变,用于表示单色调的强度变化。
-
Jet: 从蓝色到红色的渐变,涵盖从低到高的整个信号强度范围。
-
Rainbow: 包括多个颜色,常用于表达复杂的信号强度分布。
-
colorMap 添加与切换
在渲染影像时,如果不单独设置colorMap值,CT影像默认colorMap
通常是"Gray"
,意味着CT影像会以灰度图的形式显示,呈现黑白的渐变色调,以便清晰地显示解剖结构的对比度和细节。PET影像默认 colorMap
通常是 "Hot"
或类似的热图风格,使用从黑色到红色再到黄色和白色的渐变来表示影像中不同的代谢活动强度。
- 获取注册的全部colorMap主题
import vtkColormaps from "@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps";
const colorMaps = vtkColormaps.rgbPresetNames.map((presetName) =>
vtkColormaps.getPresetByName(presetName)
);
- ViewportColorbar映射(当前映射方案下调整窗宽窗位)
整体流程与正常渲染一致,这里就不再重复了,主要介绍一下如何添加colorMap的设置。
在renderingEngine.renderViewports([‘viewportId1’, ‘viewportId2’, ‘viewportId3’]); 执行完后支持以下初始化代码
new ViewportColorbar({
id: "ctColorBar", // 当前实例的ID
element: document.querySelector(`#element${item}`), // viewport的element元素
container: document.querySelector(`#colorBar${item}`), // 存放colorBar的dom元素
colormaps: colorMaps, // 加载进来的colorMap的种类
activeColormapName: currentTheme.value, // 当前使用的colorMap种类
volumeId: volumeId, // 应用的VolumeId
ticks: { // 提示信息
position: ColorbarRangeTextPosition.Left,
style: {
font: "12px Arial",
color: "#fff",
maxNumTicks: 8,
tickSize: 5,
tickWidth: 1,
labelMargin: 3
}
}
});
- 将初始化的colorBar实例应用到视图中(步骤3)
const vps = getRenderingEngine(renderingEngineId).getViewports();
vps.forEach(viewport => {
viewport.setProperties(
{
colormap: {
name: currentTheme.value // 需要设置的colorMap名称
},
opacity: 1
},
volumeId
);
viewport.render();
});
- 切换colorMap方案
// 在colorMap方案初始化并应用到视图中后,如果需要切换方案,
// 1. 修改 currentTheme.value值, 2. 并重新执行上面【步骤3】中的代码即可完成修改
- 渲染效果
点击查看完整代码,欢迎关注Star
双Volume调整窗宽窗距
当我们一个viewport中既有CT又有PET时,可能需要设置两个colorBar来分别调整他们的窗宽窗距(‼️ 如果不设置colorBar而使用windowLevel工具,存在只能调整上层Volume的问题)
- 为两个Volume分别初始化对应的ViewportColorbar
// 初始化CT的colorBar对象并应用到视图中
initColorBar(ctColorMapName, ctVolumeId);
// 初始化PT的colorBar对象并应用到视图中
initColorBar(ptColorMapName, ptVolumeId);
function initColorBar(activeColormapName, volumeId) {
elementIds.forEach((id, index) => {
new ViewportColorbar({
id: `${volumeId}ColorBar`,
element: document.querySelector(`#${id}`),
container: document.querySelector(`#colorBar${index + 1}`),
colormaps: colorMaps,
activeColormapName,
volumeId,
ticks: {
position: ColorbarRangeTextPosition.Left,
style: {
font: "12px Arial",
color: "#fff",
maxNumTicks: 8,
tickSize: 5,
tickWidth: 1,
labelMargin: 3
}
}
});
});
// setColorMapToVP 函数为上面【步骤3】中的代码
setColorMapToVP(volumeId, activeColormapName);
}
viewport透明度设置
由于融合效果是在一个viewport中设置了两个Volume,存在重叠问题,这个时候就需要设置上层Volume的透明度为一个合适的值,方便同时查看两个影像。
- 实现代码
与设置colorMap一致,colorMap的种类设置的是name属性,透明度则为opacity属性
vps.forEach(viewport => {
viewport.setProperties(
{
colormap: {
name: colorMapName,
opacity: 0.5 // 设置透明度
}
},
volumeId
);
viewport.render();
});
- 渲染效果
点击查看完整代码,欢迎关注Star
🌻 结束语
至此,关于PET-CT融合部分的介绍就全部结束了。本文从基础概念知识开始,渐进式介绍了渲染融合影像的实现步骤、数据获取及处理、colorMap颜色映射方案的实现与修改、多Volume修改窗宽窗位的方案等实现。可以涵盖在日常开发中遇到的融合渲染、标量值显示、透明度切换、窗宽窗位修改等多种需求场景。文中涉及到的代码已全部更新至 github,欢迎交流讨论 👏👏👏