Cornerstone3D源码-DICOM到图像显示的调用链

概览

本文按阶段梳理 Cornerstone3D 中从 原始 DICOM 二进制数据屏幕上最终图像 的完整调用链。

涉及的主要模块:

  • @cornerstonejs/dicom-image-loader:负责从 WADO‑URI / WADO‑RS / 本地文件获取 DICOM,并解码像素
  • dicom-parser:解析 DICOM 字节为 DataSet,并帮助处理 encapsulated 像素数据
  • @cornerstonejs/core:管理 Image / Volume / 缓存 / Viewport
  • vtk.js:负责 GPU 渲染(2D vtkImageSlice,3D vtkVolume

下面以 WADO‑URI / WADO‑RS 为主线说明(其他来源类似)。


1. 获取 DICOM 二进制

单张图像的入口通常是 DICOM Image Loader 的 loadImage(imageId, options)。 对于 wadouri: / dicomweb: schema,会走到:

43:70:packages/dicomImageLoader/src/imageLoader/wadouri/loadImage.ts 复制代码
function loadImageFromPromise(
  dataSetPromise: Promise<DataSet>,
  imageId: string,
  frame = 0,
  sharedCacheKey: string,
  options: DICOMLoaderImageOptions,
  callbacks?
): Types.IImageLoadObject {
  // ...
  dataSetPromise.then(
    (dataSet /* , xhr*/) => {
      const pixelData = getPixelData(dataSet, frame);
      const transferSyntax = dataSet.string('x00020010');
      const imagePromise = createImage(
        imageId,
        pixelData,
        transferSyntax,
        options
      );
      // ...
    }
  );
}

这里的 dataSetPromise 来自于具体 scheme 对应的 loader:

  • wadouri: / dicomweb:xhrRequest(通过 HTTP 请求 DICOMweb 接口)
  • dicomfile:loadFileRequest(本地文件)

二者最终都会拿到一个 DICOM P10 文件的 ArrayBuffer


2. 用 dicom-parser 解析为 DataSet

拿到原始字节后,由 dataSetCacheManager 使用 dicom-parser 解析为 DataSet, 并在本地做缓存,避免重复下载和重复解析:

1:8:packages/dicomImageLoader/src/imageLoader/wadouri/dataSetCacheManager.ts 复制代码
import type { DataSet } from 'dicom-parser';
import * as dicomParser from 'dicom-parser';
// ...
// 某处:
const dataSet = dicomParser.parseDicom(byteArray);

到这一步为止:

  • 原始 DICOM 字节 → Uint8ArraydicomParser.DataSet

接下来基于 DataSet 取出像素数据。


3. 从 DataSet 中提取像素 ByteArray(支持压缩与非压缩)

像素提取由 getPixelData(dataSet, frameIndex) 完成:

5:18:packages/dicomImageLoader/src/imageLoader/wadouri/getPixelData.ts 复制代码
function getPixelData(dataSet: DataSet, frameIndex = 0): ByteArray {
  const pixelDataElement =
    dataSet.elements.x7fe00010 || dataSet.elements.x7fe00008;

  if (!pixelDataElement) {
    return null;
  }

  if (pixelDataElement.encapsulatedPixelData) {
    return getEncapsulatedImageFrame(dataSet, frameIndex);
  }

  return getUncompressedImageFrame(dataSet, frameIndex);
}

对于 encapsulated(封装/压缩)像素数据 ,会进一步调用 getEncapsulatedImageFrame,内部大量使用 dicom‑parser 提供的工具函数:

1:22:packages/dicomImageLoader/src/imageLoader/wadouri/getEncapsulatedImageFrame.ts 复制代码
import type { ByteArray, DataSet } from 'dicom-parser';
import * as dicomParser from 'dicom-parser';

export default function getEncapsulatedImageFrame(
  dataSet: DataSet,
  frameIndex: number
): ByteArray {
  if (
    dataSet.elements.x7fe00010 &&
    dataSet.elements.x7fe00010.basicOffsetTable.length
  ) {
    return dicomParser.readEncapsulatedImageFrame(
      dataSet,
      dataSet.elements.x7fe00010,
      frameIndex
    );
  }

  const basicOffsetTable = dicomParser.createJPEGBasicOffsetTable(
    dataSet,
    dataSet.elements.x7fe00010
  );

  return dicomParser.readEncapsulatedImageFrame(
    dataSet,
    dataSet.elements.x7fe00010,
    frameIndex,
    basicOffsetTable
  );
}

到这一步为止:

  • DataSet → 按帧(frame)获取到 像素 ByteArray(可能是压缩的,也可能已是未压缩)。

4. 按 Transfer Syntax 解码像素并构建 Image 对象

4.1 createImage:从 ByteArray 到 Cornerstone Image

createImageByteArray + Transfer Syntax → Cornerstone Image 的核心桥梁:

24:33:packages/dicomImageLoader/src/imageLoader/createImage.ts 复制代码
function createImage(
  imageId: string,
  pixelData: ByteArray,
  transferSyntax: string,
  options: DICOMLoaderImageOptions = {}
): Promise<DICOMLoaderIImage | Types.IImageFrame> {
  // ...
  const canvas = document.createElement('canvas');
  const imageFrame = getImageFrame(imageId);
  // ...
  const decodePromise = decodeImageFrame(
    imageFrame,
    transferSyntax,
    pixelData,
    canvas,
    options,
    decodeConfig
  );
  // ...
}

4.2 decodeImageFrame:根据 Transfer Syntax 选择解码路径

真正的解码逻辑在 decodeImageFrame 中,根据 Transfer Syntax UID 分发到不同 codec, 大多数情况通过 WebWorker + 对应编解码库完成:

49:66:packages/dicomImageLoader/src/imageLoader/decodeImageFrame.ts 复制代码
function decodeImageFrame(
  imageFrame,
  transferSyntax,
  pixelData,
  canvas,
  options = {},
  decodeConfig
) {
  switch (transferSyntax) {
    case '1.2.840.10008.1.2':   // Implicit VR Little Endian
    case '1.2.840.10008.1.2.1': // Explicit VR Little Endian
    // ...
      return processDecodeTask(
        imageFrame,
        transferSyntax,
        pixelData,
        options,
        decodeConfig
      );
    // 其他如 RLE、JPEG‑LS、JPEG2000/HTJ2K 等也在这里路由
  }
}

Worker 返回时,已经得到解码后的 imageFrame.pixelData,类型是适当的 TypedArray (例如 Uint8ArrayInt16ArrayFloat32Array 等)。

4.3 在 createImage 中应用缩放、颜色转换和元数据

decodePromise 解析完成后,createImage 还会做几件关键事情:

  • 根据元数据(Rescale Slope/Intercept、Modality LUT 等)做 预缩放(preScale)
  • 视需要做 颜色空间转换(YBR→RGB 等)
  • 根据情况调用 setPixelDataType 设置最终像素类型
  • 通过 core 的 metaData 读取:
    • IMAGE_PLANE 模块(空间方向、像素间距等)
    • VOI_LUT 模块(窗宽窗位)
    • MODALITY_LUT 模块(模态 LUT)
  • 把上述信息封装成 DICOMLoaderIImage,实现 @cornerstonejs/coreTypes.IImage 接口

到这个阶段为止:

  • 像素 ByteArray → 解码为 TypedArray 像素 + 元数据 → Cornerstone Image 对象

5. 从 Image 到 core 缓存与 Viewport

5.1 Stack(2D 翻页)路径

对于 Stack(2D 多帧)场景:

  1. Image 通过 core 的 imageLoader 与 cache 进入缓存;
  2. 调用 StackViewport.setStack(imageIds) 时:
    • 从缓存中根据当前索引取出 Image
    • 利用其 voxelManager / 像素数组构造 vtkImageData
1751:1768:packages/core/src/RenderingEngine/StackViewport.ts 复制代码
createVTKImageData({
  origin,
  direction,
  dimensions,
  spacing,
  numberOfComponents,
  pixelArray,
}) {
  const values = new pixelArray.constructor(pixelArray.length);
  const scalarArray = vtkDataArray.newInstance({
    name: 'Pixels',
    numberOfComponents: numberOfComponents,
    values: values,
  });
  const imageData = vtkImageData.newInstance();
  imageData.setDimensions(dimensions);
  imageData.setSpacing(spacing);
  imageData.setDirection(direction);
  imageData.setOrigin(origin);
  imageData.getPointData().setScalars(scalarArray);
  return imageData;
}
  1. 再通过 vtkImageMapper + vtkImageSlice 创建 Actor,加入当前 Viewport 的 vtkRenderer
629:642:packages/core/src/RenderingEngine/StackViewport.ts 复制代码
private createActorMapper = (imageData: vtkImageData) => {
  const mapper = vtkImageMapper.newInstance();
  mapper.setInputData(imageData);

  const actor = vtkImageSlice.newInstance();
  actor.setMapper(mapper);
  // ...
  return actor;
};

5.2 Volume(3D 体)路径

对于 3D 体数据(多帧 DICOM 序列):

  1. 调用 volumeLoader.createAndCacheVolume(volumeId, { imageIds })
    • 内部批量调用上面的 loadImage / createImage
    • 聚合成一个 ImageVolume
      • 内含 vtkImageData(维度、spacing、origin、direction 等)
      • 内含 vtkStreamingOpenGLTexture(用于把体素数据流式上传到 GPU)
154:178:packages/core/src/cache/classes/ImageVolume.ts 复制代码
if (!imageData) {
  imageData = vtkImageData.newInstance();
  imageData.setDimensions(dimensions);
  imageData.setSpacing(spacing);
  imageData.setDirection(direction);
  imageData.setOrigin(origin);
}

imageData.set(
  {
    dataType: dataType,
    voxelManager: this.voxelManager,
    id: volumeId,
    numberOfComponents: numberOfComponents || 1,
  },
  this.suppressWarnings
);

this.imageData = imageData;
this.vtkOpenGLTexture = vtkStreamingOpenGLTexture.newInstance();
this.vtkOpenGLTexture.setVolumeId(volumeId);
  1. VolumeViewport.setVolumes([{ volumeId }]) 中通过 createVolumeActor 创建 vtkVolume Actor:
44:73:packages/core/src/RenderingEngine/helpers/createVolumeActor.ts 复制代码
const imageVolume = await loadVolume(volumeId);
const { imageData, vtkOpenGLTexture } = imageVolume;

const volumeMapper = createVolumeMapper(imageData, vtkOpenGLTexture);

const volumeActor = vtkVolume.newInstance();
volumeActor.setMapper(volumeMapper);

其中 createVolumeMapper 使用自定义的 vtkSharedVolumeMapper, 通过共享 vtkStreamingOpenGLTexture 在多 viewport 间复用 GPU 纹理。


6. RenderingEngine + vtk.js 渲染到 Canvas

所有 Viewport(Stack / Volume / Video 等)都挂在同一个 RenderingEngine 实例下:

  • 内部有一个离屏的 vtkRenderWindow(以及一个 vtkStreamingOpenGLRenderWindow
  • 每个 viewport 对应一个 vtkRenderer(id 为 viewportId)

向 Viewport 添加 Actor 时,本质上是添加到对应的 vtk renderer:

681:702:packages/core/src/RenderingEngine/Viewport.ts 复制代码
public addActor(actorEntry: ActorEntry): void {
  const { uid: actorUID, actor } = actorEntry;
  const renderingEngine = this.getRenderingEngine();
  // ...
  const renderer = this.getRenderer();
  renderer?.addActor(actor as vtkActor);
  this._actors.set(actorUID, Object.assign({}, actorEntry));
  // ...
}

当应用调用 renderingEngine.render()(或 renderViewport(viewportId))时:

  1. BaseRenderingEngine 使用 requestAnimationFrame 安排在下一帧渲染
  2. 对于需要渲染的每个 viewport:
    • 取其对应的 vtkRenderer,设置在离屏 renderWindow 中的 viewport 区域
    • 调用 renderWindow.render(),交由 vtk.js 完成 GPU 绘制(包括 image slice、volume 等全部 Actor)
  3. 渲染完成后,将离屏 WebGL canvas 中对应区域拷贝到该 viewport 对应的页面 <canvas>

到这一步为止,最初的 DICOM 二进制数据已经变成了屏幕上的像素图像。


7. 整体调用链小结

以典型的 WADO‑URI 单帧 DICOM 为例,可以将整个链路概括为:

  1. 获取字节 :HTTP / 文件 → ArrayBuffer
  2. 解析 DICOMdicomParser.parseDicomDataSet
  3. 提取帧像素getPixelDataByteArray(按 frame)
  4. 按 Transfer Syntax 解码createImagedecodeImageFrame → 解码后的 TypedArray 像素
  5. 构建 Cornerstone ImageDICOMLoaderIImage(像素 + Modality/VOI/Plane 等元数据)
  6. 缓存 / 体构建ImageVolume / StreamingImageVolume(包含 vtkImageData + vtkStreamingOpenGLTexture
  7. 构建 vtk 数据结构
    • Stack:vtkImageData + vtkImageSlice
    • Volume:vtkImageData + vtkStreamingOpenGLTexture + vtkVolume
  8. 渲染RenderingEngine + vtk.js(vtkRenderWindow + vtkRenderer)→ 离屏 canvas → 拷贝到页面 canvas

这条管线清晰地区分了三个层次的职责:

  • 解析/解码层:dicom‑parser + 各类 codec,负责从 DICOM 字节到像素数组
  • 成像语义层:Cornerstone3D core,负责 Image/Volume 抽象、元数据和 viewport 管理
  • 渲染层:vtk.js,负责在 GPU 上进行 2D/3D 渲染并输出到 canvas
相关推荐
Rsun0455111 小时前
React相关面试题
前端·react.js·前端框架
鹏多多.11 小时前
Flutter使用screenshot进行截屏和截长图以及分享保存的全流程指南
android·前端·flutter·ios·前端框架
我命由我1234515 小时前
React - state、state 的简写方式、props、props 的简写方式、类式组件中的构造器与 props、函数式组件使用 props
前端·javascript·react.js·前端框架·html·html5·js
@大迁世界17 小时前
精通 React 面试:从零到中高级
前端·javascript·react.js·面试·前端框架
无知的小菜鸡19 小时前
React 零散知识记录
前端·react.js·前端框架
我命由我1234519 小时前
React - React 初识、创建虚拟 DOM 的两种方式、jsx 语法规则、React 定义组件
前端·javascript·react.js·前端框架·html·html5·js
jingling55520 小时前
无需重新安装APK | uni-app 热更新技术实战
前端·javascript·前端框架·uni-app·node.js
SuperEugene1 天前
Vant 4 实战教程:Vue3 移动端后台管理系统从选型到开发|Vue生态精选篇
前端·javascript·vue.js·前端框架·vant
低保和光头哪个先来1 天前
TinyEditor 篇1:实现工具栏按钮向服务器上传图片
服务器·开发语言·前端·javascript·vue.js·前端框架