🔆 引言
在使用Cornerstone3D渲染影像时,有一个常用功能“设置窗宽窗位(windowWidth&windowLevel)”,通过精确调整窗宽窗位,医生能够更清晰地区分各种组织,如区别软组织、骨骼、脑组织等。本文将围绕窗宽窗位的基础概念、如何使用工具调整及工具调整的实现原理、js动态调整、MPR视图下多视图同步调整等展开。
🔎 关于窗宽窗位
窗宽窗位在医学影像学中是一项重要概念,特别是在CT和MRI中。它们主要通过调整影像的对比度和亮度来改善组织的可视化,以便于更好的观察影像中不同组织的细节。所以在介绍如何设置窗宽窗位前,先简单说明下它们是什么。
窗宽(Window Width, WW)
窗宽是指在医学影像上可视化的灰度范围。它决定了影像中最黑和最白两个点之间的对比度。
-
窗宽值越大,影像上显示的灰度差异就越小,对比度就越低
-
窗宽值越小,影像上显示的灰度差异就越大,对比度就越高
窗位(Window Level, WL)
窗位是指影像中的中间灰度值,它决定了影像灰度范围的中心。通过调整窗位,可以改变影像的亮度,进而使某些结构更加明显。
-
增加窗位值可以使影像整体变亮,有助于观察较深的结构
-
减少窗位值可以使影像整体变暗,有助于观察较浅的结构。
为什么需要设置不同的窗宽窗位
医生或影像技师可以根据需要观察的组织类型选择合适的窗宽窗位设置,以下是医学中常用的窗宽窗位设置,所以我们在设计功能时一般会将常用数据设置为快捷操作,便于直接调整。
-
脑窗: 窗宽(WW)约为 80-100 (HU),窗位(WL)约为 30-40 HU,用于优化灰质和白质的对比度,常用于检测脑部病变
-
软组织窗:窗宽(WW)约为 300-500 HU (HU),窗位(WL)约为 40-60 HU,用于观察和区分身体软组织,如肌肉、器官等
-
肺窗:窗宽(WW)约为 1500-2000 HU,窗位(WL)约为 -450 ~ -600 HU,用于观察肺部结构,能够清晰显示气道和肺实质
-
骨窗:窗宽(WW)约为 1000-1500 HU,窗位(WL)约为 250-350 HU,用于观察骨骼的细节,常用于查找骨折和其他骨骼病变
-
血管窗:窗宽(WW)约为 600-800 HU,窗位(WL)约为 120-160 HU,主要用于评估血管的情况,特别是在血管造影研究中
🪜 使用工具调整
在Cornerstone3D Tools中提供了调整窗宽窗位的工具 WindowLevelTool,操作应用于视图的WindowLevel。它提供了一种通过在图像上拖动鼠标来设置视窗的windowCenter和windowWidth的方法。
windowLevelTool 基础使用
部分关键代码,整体可运行代码可查看:在线演示
import {
addTool,
Enums as cstEnums,
destroy as cstDestroy,
ToolGroupManager,
WindowLevelTool,
} from "@cornerstonejs/tools";
// 声明注册激活工具的业务函数
addTools() {
// 顶层API全局添加
addTool(WindowLevelTool);
// 创建工具组,在工具组添加
const toolGroup = ToolGroupManager.createToolGroup(this.toolGroupId);
toolGroup.addTool(WindowLevelTool.toolName);
toolGroup.addViewport(this.viewportId1, this.renderingEngineId);
toolGroup.addViewport(this.viewportId2, this.renderingEngineId);
toolGroup.addViewport(this.viewportId3, this.renderingEngineId);
// 设置当前激活的工具
toolGroup.setToolActive(WindowLevelTool.toolName, {
bindings: [
{
mouseButton: cstEnums.MouseBindings.Primary,
},
],
});
}
WindowLevelTool 实现原理
在了解到WindowLevelTool如何使用后,那接下来我们来看一下它到底是如何执行的。
🧘 逻辑大纲梳理
在看具体的源码前,我们先大致梳理一下,如果想要在拖拽鼠标移动时更新窗宽位,我们都需要哪些数据?
-
当前窗宽和窗位值:调整时的起始点,dicom文件的元数据属性中通常包含当前的窗宽和窗位值,可以作为调整的初始值。
-
鼠标拖拽的位移数据: 水平方向的位移量和垂直方向的位移量,一般使用canvas的2D位移坐标,通常包含在事件监听中。
-
🚀 敏感度乘数(重点): 根据图像的动态范围,计算位移量对窗宽窗位的敏感度影响【这个是整个逻辑中重要且计算复杂的部分,具体实现逻辑在源码解读中展开】,
-
最新的窗宽窗位值:由以上三点计算出最新的窗宽窗位值,并赋值渲染
🏄 源码实现解读
在梳理完大致需要的数据后,我们再来看一下源码中是如何获取到这些数据,又有哪些数据是在初始梳理时被忽略掉的。
在 Cornerstone3D的官方github中找到 WindowLevelTool 这个文件,我们可以看到WindowLevelTool继承于BaseTool,但是这个不重要,不在本次讨论计划中,在整个类中,有一个 mouseDragCallback
函数,这个一看上去就像是关键函数,我们来看一下这个函数的实现。
核心目的:拿到最新的窗宽窗位值,并赋值影像渲染
👉 第一阶段:数据准备阶段
由以下流程图可见:在代码开始阶段,WindowLevelTool准备了deltaPoint
、lower
、upper
(关于lower、upper与窗宽窗距地关系及转换方式在下一章节【动态调整方案】中详细展开)、isPreScaled
、modality
等变量,我们先来看整体的执行流程
根据上面的流程逻辑,我们对应着源码来具体看一下代码是如何实现的
👉 第二阶段:最新窗宽窗位计算阶段
经过上面的代码,我们已经拿到了计算新的窗宽窗位所需要的数据,那这些数据如何组合计算才可以得到新的窗宽窗位呢?
计算窗宽窗位比较核心的步骤是:计算敏感度比率,然后有比率值得到最新的窗宽窗位值,我们先来了解一下敏感度比率的计算逻辑,然后再看源码是如何通过编程实现这一计算逻辑的。
🔥 敏感度乘数计算逻辑
-
定义一个默认的敏感度乘数:在Cornerstone中这个值为4,
const DEFAULT_MULTIPLIER = 4;
-
计算图像的动态范围:
-
**获取动态范围:**动态范围一般指图像中像素值的最大值与最小值之间的差。对于CT图像,可以通过中间切片来获取
-
**动态范围与乘数的关系:**动态范围的大小可以用来改变乘数的计算
- 计算乘数
- 一般乘数的计算为【(动态范围 || 2**元数据像素存储位置 取小)/默认动态范围】,
const DEFAULT_IMAGE_DYNAMIC_RANGE = 1024;
🔥 最新窗宽窗位的计算逻辑
-
计算窗宽偏移量:由上面得到的敏感度乘数 * 鼠标在x轴上的偏移量,就能得到窗宽的一个偏移量
-
计算窗位偏移量: 由上面得到的敏感度乘数 * 鼠标在y轴上的偏移量,就能得到窗位的一个偏移量
-
计算最新的窗宽窗位:现在的窗宽窗位加上对应的偏移量,得到最新的窗宽窗位值
以上就是整个算法中比较核心的部分,那了解完计算逻辑后,我们来看一下在Cornerstone3D的源码中,是如何通过代码实现以上的计算逻辑的(由于篇幅问题,暂不展开说明PT模式下的实现,在后续PT工具文章中再展开说明)
👉 第三阶段:为视图设置新的窗宽窗位,并渲染
经过前两个阶段,我们已经拿到了最新的窗宽窗位值,现在我们只需要将最新的窗宽窗位值重新赋值给视图,并让视图重新渲染即可。
viewport.setProperties({
voiRange: newRange,
});
viewport.render();
如果当前Volume具有多个视图的话,需要多个视图都重新渲染一下
if (viewport instanceof VolumeViewport) {
viewportsContainingVolumeUID.forEach((vp) => {
if (viewport !== vp) {
vp.render();
}
});
return;
}
至此,关于WindowLevelTools是如何设置窗宽窗位的源码已完全解读,现在大家应该基本了解了窗宽窗位都跟哪些数据相关,这些数据又是从哪里获取到的,获取到又是如何应用这些数据计算的(关于为什么能够事件的detail中获取到canvas的2d坐标的,会在后续事件监听文章中详细展开)
👩💻 动态调整方案
当我们在自己的项目中使用了WindowLevelTool,并成功激活了它,可以让用户自主调整窗宽窗距,这时产品又提出了一个新的需求,不能只让用户通过工具拖拽调整,我们应该内置一些常用的窗宽窗位让用户快速且精准的设置。
这个需求你拍脑袋一想,那直接设置几个快捷按钮不就可以了,但是快捷按钮是响应事件是什么,上面源码解读时获取到的lower
和upper
与窗宽窗位又有什么关系?
lower 与 upper
在医学影像处理时,“lower”和“upper”通常指的是窗宽调整的下限和上限值。这些值定义了在图像显示时用于映射像素值到显示器亮度的范围。
-
Lower (下限):指的是窗宽调整范围的最小边界,计算公式通常是 WL – WW/2,这里的 WL 是窗位,WW 是窗宽。
-
Upper (上限):指的是窗宽调整范围的最大边界,计算公式通常是 WL + WW/2。
如何获取lower和upper
当我们知道lower
、upper
与窗宽窗位的计算关系后,我们就可以在拿到lower&upper
后计算对应的窗宽窗位了,其实对于如何获取到lower&upper
在上面WindowLevelTool的源码中已经给出来了,它在viewport的属性中
const enabledElement = getEnabledElement(element);
const { renderingEngine, viewport } = enabledElement; // 获取viewport的方式可以依据上下文多种方案获取
const properties = viewport.getProperties(); // 获取到viewport的属性对象:properties
const { lower, upper } = properties.voiRange; // 从 properties 的voiRange属性中获取到当前视图中的 lower, upper
转换lower和upper
我们知道了(lower&upper)与(ww&wl)之间的计算方式后,虽然可以手动计算对应的 ww&wl ,但是Cornerstone本身提供了两个内置工具方法供我们转换使用
- 由 lower&upper 转 ww&wl
let { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel(
lower,
upper
);
- 由 ww&wl 转 lower&upper
let { lower, upper } = utilities.windowLevel.toLowHighRange(windowWidth, windowCenter)
假设我们已经有了按钮设置对应的窗宽窗位,以下为Vue项目中MPR视图下每个按钮对应的点击事件示例:
// windowWidth,windowLevel 为当前按钮需要设置的窗框窗距
handleWindowLevelClick(windowWidth, windowLevel) {
if (windowWidth && windowWidth) {
const { lower, upper } = csUtils.windowLevel.toLowHighRange(windowWidth, windowLevel);
[viewportId1, viewportId2,viewportId3].forEach((id) => {
const vp = this.renderingEngine.getViewport(id);
vp.setProperties({
voiRange: {
lower,
upper,
},
});
vp.render();
});
}
},
内置函数源码解读
虽然在上面给出了lower
和upper
的通用计算方式,但是在处理Dicom文件时,Dicom标准已经明确给出了相关的计算方式,具体原理可查看 dicom.nema.org/medical/dic…,在内置的工具函数中使用的计算方式即Dicom标准中给出的计算方式。
对应源码地址:github.com/cornerstone…
toLowHighRange
function toLowHighRange(
windowWidth: number,
windowCenter: number
): {
lower: number;
upper: number;
} {
const lower = windowCenter - 0.5 - (windowWidth - 1) / 2;
const upper = windowCenter - 0.5 + (windowWidth - 1) / 2;
return { lower, upper };
}
toWindowLevel
function toWindowLevel(
low: number,
high: number
): {
windowWidth: number;
windowCenter: number;
} {
// Allow for swapping high/low
const windowWidth = Math.abs(high - low) + 1;
const windowCenter = (low + high + 1) / 2;
return { windowWidth, windowCenter };
}
计算方式浅析
🤔 在计算lower
和 upper
时为什么窗宽 -1 ?
窗宽定义为要显示的灰度范围的宽度。在考虑窗的两端时,减去 1 是为了确保窗宽覆盖的是指定的像素范围内完整的单位数,减去的是开始的中心点。例如我们想要一个6个单位的窗宽时,减1主要是如下进行的:
-
准确的窗边界定位:窗宽为6意味着从窗位中心开始,向每侧扩展出去的范围一共涵盖6个单位。在不减1的情况下,如果直接将窗宽的一半加/减到窗位上,可能会导致计算的范围实际上比预期宽或窄,因为这种计算可能不会精确考虑到窗位中心所在的那一个单位。
-
确保窗宽精确覆盖期望的单位数:通过减去1后再除以2,实际上是在计算从窗位中心点向两边扩展时,确切地排除了中心点占用的那一个单位,然后均匀分配剩余的窗宽到中心点的两侧。这样做确保了,不管窗位中心点如何定位,从中心点向两侧扩展出的范围总是精确地覆盖了除中心点外的额外5个单位,从而确保整个窗宽为6个单位。
🤔 在计算
lower
和upper
时为什么窗位 – 0.5 ?
窗位减去0.5,是为了在计算时能够处理半个像素单位的偏移,这样做有助于更精确地定位和调整图像窗的中心。
这种微调主要是考虑到像素值通常是整数,而窗宽和窗位的调整可能需要更细致的控制,特别是在灰度值的分布和转换过程中。减去0.5是一种常用的技巧,以确保在离散的像素值和连续的窗宽调整之间达到更好的对应和平滑过渡。
📡 多视图同步
当我们终于搞定动态设置窗宽窗距后,产品又又又又提了个需求:在MPR视图时,调整其中一个视图的窗宽窗位,其他两个要同步响应💥
听完这个需求后,第一反应是这还不简单,我都知道怎么动态设置了,设置个同步还不是手到擒来,
-
先监听每个视图的VOI变化
-
当他变化时将拿到的窗宽窗位动态设置给其他视图
但是这么一想,一方面要监听多个视图,还容易一不小心就陷入个死循环,有没有更好的实现方式呢?当然有:那就是之前提的同步器,以下为示例代码
import {
SynchronizerManager,
synchronizers,
} from '@cornerstonejs/tools';
// 使用内置的createVOISynchronizer,创建一个VOI同步器
synchronizers.createVOISynchronizer(‘VOI_SYNCHRONIZER_ID’);
// 获取创建的VOI同步器
const voiSynchronizer = SynchronizerManager.getSynchronizer(‘VOI_SYNCHRONIZER_ID’);
// 为同步器添加同步视图
[viewportid1, viewportid2, viewportid3].forEach((viewportId) => {
voiSynchronizer.add({
renderingEngineId,
viewportId,
});
});
这样我们就为每个视图添加了同步,当变化的时候会同步变化(由于篇幅问题,这里就不展开详细讲同步器相关源码实现了,会在后续自定义同步器中展示详说)
🎉 结语
到这里,窗宽窗位相关的知识点、3种场景下的设置方案及源码解读就介绍,欢迎交流沟通任何Cornerstone3D相关知识点 👏
本系列为从0上手Cornerstone3D系列文章,包括cornerstone核心概念、基础使用、常见案例、工具使用、运行原理、源码解读等等,欢迎Start演示Github:github.com/jianyaoo/vu… 交流更多相关使用技巧~
- CornerStone3D核心概念:juejin.cn/post/732643…
- Cornerstone3DTools常用工具:juejin.cn/post/733030…
原文链接:https://juejin.cn/post/7344881744245948453 作者:云小遥