简介:这是一份面向医疗影像与前端开发者的实战源码,基于 Vue3 与 cornerstone3D 构建 DICOM 影像浏览器,帮助读者理解如何在 Web 端渲染医学影像、组织单页应用结构并接入网络请求获取影像数据。资源共 137 个文件,以 56 个 js 脚本、28 个 vue 组件、27 个 png 与 13 个 jpg 图像资源为主,另含 svg、json、scss、html 及构建与格式化配置,压缩包约 800KB,目录结构清晰,便于按模块检索。项目集成 Vite 构建工具、npm 依赖管理与 Prettier 代码格式化,配套 README 与版权说明文档,可帮助开发者快速搭建专业影像浏览环境。目前已有 812 人学习下载,适合希望掌握 cornerstone3D 渲染流程、Vue3 组件拆分与工程化配置的前端及医疗信息化从业者参考借鉴。
1. 从一次翻车现场说起:这套源码到底能干什么
去年帮一个做医疗软件的朋友救火,他们用某开源方案搭的 DICOM 浏览器,加载一个 800MB 的 CT 序列直接白屏,控制台报的是 WebGL 上下文丢失。排查了两天才发现,问题出在渲染层没有做分块加载,一次性把几百张切片全塞进显存。后来我换了一套基于 cornerstone3D 的架构重新搭,同样的数据量,首屏出来只用了不到三秒。那次之后我就开始留意 cornerstone3D 相关的完整项目,今天拆的这套源码,就是一个用 Vue3 加 cornerstone3D 搭起来的 DICOM 影像浏览器,带完整的工具链和视图层封装。
它解决的核心问题很明确:让你不用从零去啃 cornerstone3D 那套偏底层的 API,直接拿到一个能跑起来的工程骨架。适合谁呢?一是做医疗影像 Web 端开发、需要快速出原型的团队;二是想学 cornerstone3D 但被官方示例劝退的开发者;三是手里有 DICOM 数据、想找个现成壳子套进去用的从业者。源码里把视图初始化、工具组注册、序列滚动、窗宽窗位调节这些高频操作都封装好了,你拿到手改改配置就能接自己的数据源。
2. 环境搭建与工程结构:从 clone 到首屏出图
2.1 依赖版本与初始化命令
这套源码基于 Vue3 的 Composition API 写的,构建工具用的是 Vite。我拿到手第一件事是看 package.json 里的依赖版本,因为 cornerstone3D 的版本迭代比较快,不同版本之间 API 差异不小。源码里锁的是 cornerstone3D 的核心包加几个常用工具包,下面是我整理出来的关键依赖和对应的安装命令。
# 创建项目目录并进入 mkdir dicom-viewer && cd dicom-viewer # 用 Vite 初始化一个 Vue3 项目(如果源码里已经带了工程结构,这步跳过) npm create vite@latest . -- --template vue # 安装 cornerstone3D 核心依赖 npm install @cornerstonejs/core @cornerstonejs/tools @cornerstonejs/dicom-image-loader # 安装 dicom 解析相关的辅助库 npm install dicom-parser # 安装开发依赖 npm install -D vite-plugin-vue-devtools这里有几个参数需要留意。@cornerstonejs/core是渲染引擎,负责把像素数据画到 canvas 上;@cornerstonejs/tools提供窗宽窗位、缩放、平移、测量这些交互工具;@cornerstonejs/dicom-image-loader负责解析 DICOM 文件并转成 cornerstone 能识别的图像对象。三个包缺一不可,版本上建议保持一致的大版本号,混用容易出现内部 API 对不上的情况。
安装完之后,在 main.js 里做全局初始化。这一步很多人会漏掉,导致后面调用工具时报「toolGroup not found」。
// main.js import { init as coreInit } from '@cornerstonejs/core'; import { init as dicomImageLoaderInit } from '@cornerstonejs/dicom-image-loader'; import { init as toolsInit } from '@cornerstonejs/tools'; async function bootstrap() { // 初始化渲染引擎,底层会创建 WebGL 上下文 await coreInit(); // 初始化 DICOM 加载器,注册 wadouri 和 wadors 两种协议 await dicomImageLoaderInit(); // 初始化工具库,注册内置工具 await toolsInit(); // 挂载 Vue 应用 createApp(App).mount('#app'); } bootstrap();逻辑说明:coreInit必须在最前面,因为它会初始化 GPU 相关的资源池;dicomImageLoaderInit依赖 core 的配置;toolsInit又依赖前两者。顺序错了会在控制台看到一堆 undefined 报错。参数方面,dicomImageLoaderInit可以传入maxWebWorkers来控制解析并发数,默认是navigator.hardwareConcurrency,如果机器核数多但内存小,建议手动限制到 4 以下,否则解析大序列时容易把内存打满。
2.2 目录结构与核心模块职责
源码的目录结构我按职责拆成了几块,这样你拿到手能快速定位要改哪里。
| 目录/文件 | 职责 | 改动频率 |
|---|---|---|
src/views/Viewport.vue | 视图容器组件,负责挂载 canvas 和绑定工具组 | 低 |
src/utils/dicomLoader.js | DICOM 文件加载与 imageId 生成 | 中 |
src/utils/toolGroup.js | 工具组注册与激活逻辑 | 中 |
src/config/rendering.js | 渲染参数,如窗宽窗位预设、缩放范围 | 高 |
src/api/ | 与后端 PACS 或文件服务的对接层 | 高 |
Viewport.vue是整个浏览器的核心,它内部会调用renderingEngine.enableElement把 canvas 和 cornerstone 的渲染引擎绑定起来。dicomLoader.js里封装了wadouri协议的 imageId 拼接逻辑,格式是wadouri:http://your-server/dicom/{studyId}/{seriesId}/{instanceId}。如果你用的是本地文件,需要改成wadouri:blob:加 File 对象的方式。
工具组的注册在toolGroup.js里,源码默认注册了窗宽窗位、缩放、平移、探针、长度测量这几个。我一般会根据自己的需求增删,比如做骨科测量就要加角度工具,做心脏分析可能要加椭圆 ROI。
3. 核心渲染链路拆解:从 DICOM 文件到屏幕像素
3.1 imageId 生成与加载器配置
cornerstone3D 不直接认 DICOM 文件,它认的是 imageId。imageId 是一个字符串,格式决定了用哪个加载器去解析。源码里用的是wadouri协议,适合通过 HTTP 拉取单个 DICOM 文件。下面这段是加载单个序列的典型写法。
// utils/dicomLoader.js import { imageLoader } from '@cornerstonejs/core'; import dicomImageLoader from '@cornerstonejs/dicom-image-loader'; /** * 根据序列信息生成 imageId 列表 * @param {string} baseUrl - DICOM 文件服务的基础地址 * @param {Array} instances - 实例列表,每项包含 sopInstanceUID * @returns {Array<string>} imageId 数组 */ export function buildImageIds(baseUrl, instances) { return instances.map((item) => { // wadouri 协议前缀不能少,否则加载器不识别 return `wadouri:${baseUrl}/${item.sopInstanceUID}.dcm`; }); } /** * 预加载指定范围的切片,避免滚动时白屏 * @param {Array<string>} imageIds - 全量 imageId * @param {number} start - 起始索引 * @param {number} count - 预加载数量 */ export function preloadImageIds(imageIds, start, count) { const slice = imageIds.slice(start, start + count); // 逐个触发加载,加载器内部有缓存,重复调用不会重复请求 slice.forEach((imageId) => { imageLoader.loadAndCacheImage(imageId).catch((err) => { console.warn(`预加载失败: ${imageId}`, err); }); }); }逻辑说明:buildImageIds把后端返回的实例列表转成 cornerstone 能识别的 imageId 数组。preloadImageIds是性能优化的关键,源码里在序列滚动事件里调用了它,提前把当前切片前后各 10 张加载进缓存。参数上,count不建议设太大,我一般设 10 到 15,设到 30 以上在低配机器上反而会因为并发请求过多导致卡顿。
加载器本身还有一些配置项,比如beforeSend可以往请求头里塞 token,requestPool可以控制并发数。这些在源码的dicomLoader.js顶部有注释说明,改的时候注意别把wadouri前缀弄丢了,丢了之后报的错是「no image loader for imageId」,新手很容易在这里卡住。
3.2 渲染引擎初始化与视口绑定
渲染引擎是 cornerstone3D 的核心,它管理 GPU 资源和渲染循环。一个页面可以创建多个渲染引擎,但一般一个就够了。视口是渲染引擎里的一个逻辑单元,对应一块 canvas。
// utils/renderingEngine.js import { RenderingEngine, Enums } from '@cornerstonejs/core'; let renderingEngine = null; export function getRenderingEngine() { if (!renderingEngine) { // 创建渲染引擎,传入一个唯一 id renderingEngine = new RenderingEngine('dicomViewer'); } return renderingEngine; } /** * 启用视口并绑定到指定 DOM 元素 * @param {string} viewportId - 视口唯一标识 * @param {HTMLElement} element - canvas 的父容器 */ export function enableViewport(viewportId, element) { const engine = getRenderingEngine(); // 视口类型选 STACK,适合逐张浏览的序列 engine.enableElement({ viewportId, type: Enums.ViewportType.STACK, element, defaultOptions: { background: [0, 0, 0], // 黑色背景,符合影像科阅片习惯 }, }); }逻辑说明:RenderingEngine的构造函数参数是一个 id,这个 id 在后续调用getViewport时要用到。enableElement的type参数有两个常用值:STACK和VOLUME。STACK适合逐张切片浏览,内存占用低;VOLUME适合做 MPR 多平面重建,但需要先把整个序列构建成 volume,对内存要求高。源码默认用的是STACK,如果你要做三维重建,需要改成VOLUME并额外调用createAndCacheVolume。
defaultOptions.background设成黑色是有讲究的,影像科医生看惯了黑底白图,换成白底会严重影响判读。这个细节源码里处理得不错,但很多新手自己搭的时候会忽略。
视口绑定完之后,还需要设置数据。源码里是在Viewport.vue的onMounted里调用viewport.setStack(imageIds, currentIndex)。currentIndex是初始显示的切片索引,一般设 0 或者中间位置。设完之后调engine.render()才会真正出图,漏掉这一步屏幕就是黑的。
4. 工具组与交互逻辑:窗宽窗位、缩放、测量的实现细节
4.1 工具组注册与激活流程
工具组是 cornerstone3D 里管理交互工具的容器。一个工具组可以绑定多个视口,工具在工具组里注册后,还需要激活才能响应鼠标事件。源码里的流程分三步:创建工具组、添加工具、设置激活状态。
// utils/toolGroup.js import { ToolGroupManager, WindowLevelTool, ZoomTool, PanTool, LengthTool } from '@cornerstonejs/tools'; export function setupToolGroup(toolGroupId, viewportId) { // 如果已存在同名工具组,先销毁,避免热更新时重复注册 const existing = ToolGroupManager.getToolGroup(toolGroupId); if (existing) { ToolGroupManager.destroyToolGroup(toolGroupId); } // 创建工具组并绑定视口 const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); toolGroup.addViewport(viewportId, 'dicomViewer'); // 添加工具实例 toolGroup.addTool(WindowLevelTool.toolName); toolGroup.addTool(ZoomTool.toolName); toolGroup.addTool(PanTool.toolName); toolGroup.addTool(LengthTool.toolName); // 设置默认激活状态:鼠标左键窗宽窗位,中键平移,右键缩放 toolGroup.setToolActive(WindowLevelTool.toolName, { bindings: [{ mouseButton: 1 }], }); toolGroup.setToolActive(PanTool.toolName, { bindings: [{ mouseButton: 2 }], }); toolGroup.setToolActive(ZoomTool.toolName, { bindings: [{ mouseButton: 4 }], }); // 测量工具默认不激活,通过按钮切换 toolGroup.setToolPassive(LengthTool.toolName); return toolGroup; }逻辑说明:addViewport的第二个参数是渲染引擎的 id,必须和创建RenderingEngine时传的 id 一致,否则工具不生效。setToolActive的bindings里mouseButton的值对应关系是:1 左键、2 中键、4 右键。这个映射和浏览器原生事件不一样,新手容易搞混。setToolPassive表示工具已注册但不响应事件,适合测量类工具,等用户点按钮再激活。
源码里还处理了一个细节:在组件卸载时调用ToolGroupManager.destroyToolGroup,防止内存泄漏。这个在单页应用里很重要,不销毁的话切换路由再回来会创建多个工具组,导致事件重复响应。
4.2 窗宽窗位调节的数值边界处理
窗宽窗位是影像浏览里最高频的操作,它的本质是把 CT 值映射到 0 到 255 的灰度区间。cornerstone3D 的WindowLevelTool已经封装了鼠标拖拽逻辑,但源码里额外加了一层边界保护,因为不同模态的 CT 值范围差异很大。
// config/rendering.js export const WINDOW_LEVEL_PRESETS = { // 软组织窗 softTissue: { windowWidth: 400, windowCenter: 40 }, // 肺窗 lung: { windowWidth: 1500, windowCenter: -600 }, // 骨窗 bone: { windowWidth: 2000, windowCenter: 300 }, // 脑窗 brain: { windowWidth: 80, windowCenter: 40 }, }; /** * 应用窗宽窗位预设,并做边界钳制 * @param {Object} viewport - cornerstone 视口对象 * @param {string} presetName - 预设名称 */ export function applyWindowLevelPreset(viewport, presetName) { const preset = WINDOW_LEVEL_PRESETS[presetName]; if (!preset) return; const { windowWidth, windowCenter } = preset; // 窗宽不能小于 1,否则图像会变成纯色 const safeWidth = Math.max(windowWidth, 1); // 窗位钳制在合理范围内,避免拖拽出界后图像全黑或全白 const safeCenter = Math.min(Math.max(windowCenter, -1024), 3071); viewport.setProperties({ voiRange: { lower: safeCenter - safeWidth / 2, upper: safeCenter + safeWidth / 2, }, }); viewport.render(); }逻辑说明:voiRange是 cornerstone3D 里控制窗宽窗位的属性,lower和upper分别是映射区间的下限和上限。safeWidth和safeCenter的钳制范围是根据 12 位 CT 值的理论范围定的,最小值 -1024 对应空气,最大值 3071 对应金属。超出这个范围没有意义,反而会让用户以为程序卡死。
源码里还做了一个优化:在鼠标拖拽过程中用requestAnimationFrame节流渲染,避免每移动一个像素就触发一次重绘。这个优化在低端设备上效果很明显,拖拽的跟手程度提升不少。
5. 避坑与排查:那些让我加班到凌晨的报错
5.1 现象:控制台报「WebGL context lost」,画面变黑
原因:一次性加载的切片数量过多,显存被占满,浏览器主动回收了 WebGL 上下文。常见于序列切片超过 500 张、且没有做分块加载的场景。
解决:在dicomLoader.js里限制预加载数量,同时监听webglcontextlost事件做恢复。恢复逻辑是重新调用renderingEngine.enableElement并重新设置数据。源码里已经内置了预加载限制,但如果你自己改大了count参数,这个问题就会复现。
5.2 现象:图像显示出来了,但窗宽窗位拖拽没反应
原因:工具组没有正确绑定到视口,或者setToolActive的bindings配置被覆盖了。还有一种可能是 canvas 上层有透明遮罩,鼠标事件被拦截了。
解决:先检查ToolGroupManager.getToolGroup返回的对象里viewports是否包含当前视口 id。然后检查setToolActive是否在enableElement之后调用。最后用开发者工具的元素审查,看 canvas 的父容器有没有pointer-events: none或者高 z-index 的兄弟节点。
5.3 现象:序列滚动到中间某张时卡住,后面的加载不出来
原因:imageId 拼接规则和后端实际的文件路径不匹配。比如后端返回的sopInstanceUID里带了点号,而 URL 里点号被截断了。
解决:在buildImageIds里对sopInstanceUID做encodeURIComponent处理。另外检查后端服务是否对单 IP 做了并发限制,并发数超过阈值时后面的请求会被拒绝。可以在dicomImageLoader的配置里把maxWebWorkers调小,降低并发。
5.4 现象:测量工具画出来的线位置偏移
原因:canvas 的 CSS 尺寸和实际像素尺寸不一致,导致鼠标坐标换算错误。常见于用了transform: scale或者父容器有 padding 的情况。
解决:确保 canvas 的width和height属性与clientWidth、clientHeight一致。如果用了缩放,需要在resize事件里调用renderingEngine.resize()重新计算。源码里在Viewport.vue的onResize里处理了这个问题,但如果你自己改了布局,需要同步更新。
5.5 现象:热更新后工具重复响应,画一条线出来两条
原因:Vite 的热更新机制导致组件重新挂载,但旧的工具组没有被销毁,新旧工具组同时监听鼠标事件。
解决:在setupToolGroup开头加destroyToolGroup的判断,源码里已经加了。另外在 Vue 组件的onUnmounted里也要调一次销毁。如果还不行,检查ToolGroupManager的全局缓存是不是有残留,可以手动调ToolGroupManager.destroyToolGroup清掉。
6. 进阶技巧:用 volume 做 MPR 重建与性能调优
源码默认的STACK模式只能逐张看,如果你要做多平面重建,需要切换到VOLUME模式。这个切换不是改个枚举就完事,中间有几个关键步骤。
第一步是构建 volume。把所有切片的 imageId 传给volumeLoader,它会异步构建一个三维体数据。
import { volumeLoader, cache } from '@cornerstonejs/core'; async function buildVolume(volumeId, imageIds) { // 定义 volume 的元信息,spacing 和 origin 从第一张切片的元数据里取 const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds, }); // 触发实际加载,这一步是异步的,不 await 的话 volume 是空的 await volume.load(); return volume; }逻辑说明:createAndCacheVolume只是注册了 volume 的元信息,真正的像素数据加载在volume.load()里。volumeId是一个自定义字符串,后续在视口里通过它来引用这个 volume。构建完成后,把视口的类型改成VOLUME,然后调viewport.setVolumes([{ volumeId }])。
性能调优方面,我踩过最大的坑是内存。一个 512x512x300 的 CT 序列,原始数据大概 150MB,但构建成 volume 后,cornerstone 内部会做一次 float32 的转换,内存占用直接翻到 600MB 以上。如果同时开三个视口做 MPR,内存很容易突破 2GB,浏览器标签页直接崩掉。
我的做法是:只在需要 MPR 的时候构建 volume,看完就调cache.removeVolumeLoadObject(volumeId)释放掉。另外在rendering.js里把preferSizeOverAccuracy设成true,让 cornerstone 用 16 位整数存储体数据,内存能省一半,代价是窗宽窗位调节的精度略微下降,但对诊断影响不大。
还有一个技巧是控制渲染帧率。cornerstone3D 默认是每次属性变化都触发渲染,在拖拽窗宽窗位时渲染频率很高。可以在renderingEngine初始化时传入renderer: { antialias: false }关掉抗锯齿,帧率能提升 20% 左右。如果还嫌卡,可以在拖拽开始时调engine.setViewportsForRendering只渲染当前视口,拖拽结束再恢复。
从那以后我每次接新项目,都会先把预加载数量、volume 内存上限、渲染帧率这三个参数过一遍,确认在目标设备上跑得动再往下做功能。希望帮到你。
本文还有配套的精品资源,点击获取