☰
HTML5浏览器DICOM阅片:cornerstone+PACS实战
2026/10/27 17:06:39 网站建设 项目流程

简介:面向医学影像开发者的HTML5阅片工作站演示工程,围绕DICOM影像在浏览器端的浏览与操作展开,适合想了解PACS影像如何在前端呈现的开发者与医疗信息化从业者。压缩包共278个文件、约7.46MB,主体为188个JavaScript脚本与40个HTML页面,另有18篇Markdown说明文档、5个sourcemap映射、3个CSS样式表及若干GIF操作演示图,构成一个可本地运行的前端项目。核心依托Cornerstone.js开源影像显示库,含cornerstoneToolsExample工具示例,涉及窗宽窗位调节、缩放、平移、序列滚动以及长度、角度等测量工具的调用方式,可对照学习图像加载、工具组启用与视口渲染的组织思路。目前已有2357人学习。读者可借此理清DICOM影像在浏览器中的加载链路与交互实现,作为自建阅片模块的参考骨架。

1. 从一台装不了客户端的阅片电脑说起:pacsHtml5dicomviewer 到底解决了什么

影像科室里经常有这种机器:系统锁得很死,装不了任何客户端,但值班的人又必须看一眼影像。pacsHtml5dicomviewer 这个仓库,就是冲着这个场景做的——一个纯前端跑起来的 DICOM 阅片 demo,打开浏览器、输入检查号、拿到 PACS 里的序列,在页面上滚轮翻片、拉窗宽窗位。它不是一个成品 PACS 客户端,而是把「浏览器怎么把 DICOM 像素画到 canvas 上」这条链路完整摊开给你看的最小工程。

适合两类人:一类是手上有个 PACS 或者 DICOM 服务端,想在 Web 端快速接一个看图能力的前后端开发;另一类是刚接触 dicom 协议,想找一个能直接跑、代码量不大、能把 imageId、窗宽窗位、序列堆栈这些概念一次看明白的入门样本。整个项目的厚度刚好卡在「抄得动」和「看得懂」之间,不塞多余业务逻辑。

2. 技术选型与目录结构:为什么绕不开 cornerstone 这套组合

先给结论:这个 demo 的骨架是 cornerstone 生态三件套——cornerstone-core管视口和渲染,cornerstone-wado-image-loader管从 PACS 拉图和 DICOM 解码,cornerstone-tools管交互工具。想自己用原生 canvas 从零啃 DICOM,理论上可行,但在 demo 这个体量上没有任何性价比。

2.1 为什么不是原生 canvas 硬写,也不是上 vtk.js

DICOM 不是一张 JPG。一张 CT 影像的像素值可能是 12 位甚至 16 位有符号整数,RescaleSlope/RescaleIntercept要换算成 HU 值,然后按窗宽窗位线性映射到 0~255 再上屏。原生 canvas 写的话,ImageData的Uint8ClampedArray你得自己填,逐像素算:

// 窗宽窗位映射的核心逻辑,本质上就是一次线性裁剪 + 拉伸 const min = center - width / 2; const max = center + width / 2; const scale = 255 / (max - min); for (let i = 0; i < pixelCount; i++) { // rawData 是解码后的原始像素,可能是 Int16 const hu = rawData[i] * slope + intercept; const v = (hu - min) * scale; outU8[i] = v < 0 ? 0 : v > 255 ? 255 : v; }

这段代码本身不难,难的是它前后的一整套:传输语法(隐式 VR 小端、显式 VR 大端、JPEG Lossless、JPEG2000、RLE)、多帧影像、序列切片排序、预取、内存回收。这些 cornerstone 的 loader 都已经处理了,还有 web worker 池做解码,主线程不会被卡死。demo 阶段我一般不会去重造这部分轮子。

那为什么不直接上 vtk.js?vtk.js 强在 MPR 多平面重建、体绘制、3D 渲染,一个序列能从轴位重组成冠状位矢状位。代价是学习曲线陡、包体积大、配置复杂。阅片 demo 的核心诉求是「二维序列能翻、能调窗、能量」,这个量级上 cornerstone 更轻。等真要做 MPR 了再换,不必一开始就上重武器。

cornerstone-wado-image-loader的作用是给每个 imageId 加协议前缀,并接管后续的 HTTP 请求和解析。它支持的wadouri:前缀就是「拿 HTTP 拉一份 application/dicom 原始流回来」。

2.2 目录结构:一个页面级的工程组织

这个项目没有构建步骤,是典型的脚本标签直接引库的写法。结构大致是这样:

pacsHtml5dicomviewer/ ├── index.html # 入口页:输入检查号、跳转阅片页 ├── viewer.html # 阅片页:视口容器 + 工具栏 ├── css/ │ ├── base.css # 全局重置、深色底(阅片必须要暗背景) │ └── viewer.css # 视口、缩略图列表、工具按钮样式 ├── js/ │ ├── config.js # PACS 地址、路径、超时、预取条数 │ ├── pacs.js # QIDO 检索 + imageId 拼装 │ ├── viewer.js # cornerstone 初始化、渲染、事件绑定 │ ├── tools.js # 工具注册与激活 │ └── main.js # 页面入口逻辑 ├── lib/ # cornerstone 各版本 js 与 web worker 脚本 └── data/ # 若干本地 .dcm 文件,用于离线自测

index.html和viewer.html拆成两页,是为了让「检索列表」和「阅片」解耦——很多真实科室的工作流也是先看列表再点开。config.js单独拎出来这点很关键,PACS 地址是这个项目里唯一必须改的地方,集中在一处比散在十个文件里强太多。data/目录放本地 dcm,是给没有 PACS 环境的人留的退路,改两行配置就能切到本地文件模式,这个设计在演示和培训时特别好用。

2.3 依赖清单与版本约束

这类前端医学影像项目最容易踩的坑就是版本对不上。两个大版本之间 API 名字就变了。我给一份能跑通的组合:

依赖用途常见可用版本关键注意点
cornerstone-core视口 / 渲染2.6.xenable后必须先displayImage才有 viewport
cornerstone-wado-image-loader拉图 + 解码4.x4.x 用webWorkerManager.initialize,5.x 换成了init接口
cornerstone-tools交互工具4.x4.x 是init(),3.x 传参不一样
dicom-parserDICOM 解析1.8.x4.x 的 loader 要求把它挂到window.dicomParser
hammerjs手势2.0.x工具层依赖,别忘了引

版本约束里最容易翻车的是 loader 和 tools 的搭配。loader 4.x 配 tools 4.x 是稳的组合,loader 4.x 配 tools 3.x 会出现工具能加上、但鼠标拖动没反应,因为 tools 3.x 内部还在走老的 imageLoader 接口。这个我在下面排查章还会再提一次。

cornerstone-wado-image-loader4.x 还有一条硬要求:解码是在 web worker 里做的,worker 脚本路径必须显式和页面路径对上,否则控制台会给你一个「webWorkerManager未初始化」或者一串 404,页面上就是干干净净的白屏。

3. 核心链路拆解:一个 DICOM 从 PACS 到 canvas 的完整过程

这一章按数据流顺序走:查序列 → 拼 imageId → 拉流解码 → 渲染上屏 → 工具接管。

3.1 第一步不是取图,是 QIDO 查序列

很多人上手就直接拼 WADO 的 URL 拉图,结果发现不知道 seriesUID 从哪来。正常的顺序是先查。PACS 一般同时提供 QIDO-RS(检索)和 WADO-RS / WADO-URI(取图)两类接口,QIDO 返回的是 DICOM JSON 格式的元数据,不是影像本身。

// js/pacs.js —— 按检查号查序列列表 async function querySeries(studyUid) { // QIDO 路径形如 /dicom-web/studies/{studyUid}/series const url = `${PACS_CONFIG.baseUrl}${PACS_CONFIG.qidoPath}/${encodeURIComponent(studyUid)}/series`; const resp = await fetch(url, { headers: { Accept: 'application/dicom+json' }, // 必须声明,否则部分服务端返回 XML signal: AbortSignal.timeout(PACS_CONFIG.timeout) }); if (!resp.ok) throw new Error(`QIDO 查询失败: HTTP ${resp.status}`); const seriesList = await resp.json(); return seriesList.map((item) => ({ seriesUid: pickTag(item, '0020000E'), // SeriesInstanceUID modality: pickTag(item, '00080060'), // Modality,CT/MR/CR description: pickTag(item, '0008103E'), // SeriesDescription instanceCount: Number(pickTag(item, '00201209')) || 0 })); } // DICOM JSON 的每个 tag 都是 { vr, Value: [...] },要往下剥一层 function pickTag(dataset, tag) { const node = dataset[tag]; if (!node || !node.Value || node.Value.length === 0) return ''; return node.Value[0]; }

几个参数说明一下。Accept: application/dicom+json不是可选项,DICOMweb 规范里服务端可以按 Accept 协商返回格式,不写有可能给你回一份 XML,resp.json()直接抛异常。AbortSignal.timeout是给慢查询兜底,科室网络偶尔会有查一个患者卡十几秒的情况,没超时的话界面上就是一个转不完的圈。00201209是NumberOfSeriesRelatedInstances,用来在列表里显示「这个序列有多少张」,也是判断要不要预取更多切片的依据。

pickTag这个函数看着琐碎,但 DICOM JSON 的嵌套结构是新手第一个绊脚石——它把每个 tag 包成{vr, Value},Value是数组,多值 tag(比如 ImageType)会有好几项,直接取[0]在单值 tag 上是安全的。

3.2 imageId:cornerstone 认识的那种 URL

cornerstone 不认裸 URL,它认带协议前缀的 imageId。这个前缀告诉 loader 用哪套加载器去处理。

// js/pacs.js —— 把一串 SOP Instance UID 变成 cornerstone 能吃的 imageId function buildWadoImageId({ studyUid, seriesUid, sopUid }) { const query = [ 'requestType=WADO', `studyUID=${encodeURIComponent(studyUid)}`, `seriesUID=${encodeURIComponent(seriesUid)}`, `objectUID=${encodeURIComponent(sopUid)}`, 'contentType=application/dicom' // 要原始 DICOM 流,不要转好的 JPEG ].join('&'); // wadouri: 前缀 = 用 XHR 取原始字节,交给 dicomParser + web worker 解码 return `wadouri:${PACS_CONFIG.baseUrl}${PACS_CONFIG.wadoPath}?${query}`; }

WADO-URI 的四个参数里,requestType=WADO和contentType=application/dicom是两个必须的约定项。contentType尤其容易忽略——如果写成image/jpeg,服务端可能返回一张已经压过的 8 位图,窗宽窗位就再也调不动了,因为原始灰度信息在服务端就被砍掉了。

encodeURIComponent包住 UID 也是血泪经验。UID 里只有点和数字,理论上不编码也过得去,但一旦中间层服务端把 UID 拼进别的参数里,不做编码就容易出莫名其妙的 400。

拿到 imageIds 数组之后,用 cornerstone-tools 的 stack 状态把它挂到视口上:

// js/viewer.js —— 把一个序列绑定成可滚动的堆栈 function bindStack(element, imageIds) { const stack = { imageIds, currentImageIdIndex: 0 }; // 先注册 stack 状态管理器,后面 tools 才能读写当前索引 cornerstoneTools.addStackStateManager(element, ['stack']); cornerstoneTools.addToolState(element, 'stack', stack); // 预取:滚轮还没滚到的那几张,提前拉进内存池 cornerstoneTools.stackPrefetch.enable(element); }

addStackStateManager这行不能省。少了它,工具层能加得上,但滚轮翻片时currentImageIdIndex不会更新,表现就是「滚了没反应」。stackPrefetch.enable是按当前索引前后各取几张,默认策略比较保守,如果序列是三四百张的薄层 CT,可以在config.js里把预取范围调大一点,但别一把梭全预取,那会瞬间打满浏览器到 PACS 的连接数。

3.3 解码和渲染:窗宽窗位到底改的是哪个对象

图上屏这一步,最容易混淆的是「改窗宽窗位到底改了什么」。答案是改 viewport,不是改 image。

// js/viewer.js —— 加载一张图并给它一个初始窗宽窗位 async function showImage(element, imageId) { const image = await cornerstone.loadAndCacheImage(imageId); // displayImage 内部会用 image 的元数据建一个初始 viewport cornerstone.displayImage(element, image); const viewport = cornerstone.getViewport(element); // DICOM 头里自带 WindowCenter / WindowWidth 就优先用,没有就给个腹部窗兜底 viewport.voi.windowWidth = image.windowWidth || 400; viewport.voi.windowCenter = image.windowCenter || 40; viewport.voi.windowWidth = Math.max(viewport.voi.windowWidth, 1); // 防止为 0 时除零 cornerstone.setViewport(element, viewport); }

image.windowWidth可能是数组,因为 DICOM 标准允许多组推荐窗值,cornerstone 取第一组。如果头里没有,就得自己给默认值。这里给的 400/40 是个偏腹部的窗口,CT 上勉强能看,但 MR 序列基本全黑或者全白——不同模态的默认窗差别很大,正经做的话应该在config.js里按 modality 建一张默认窗表,切序列时按模态取。

loadAndCacheImage是带缓存的,重复翻同一张不会重复请求,这一点在来回滚轮对比的时候体感差别很明显。但缓存不是无限的,长序列翻到后面会触发淘汰,这个在排查章里会讲怎么观察。

真正干解码活的是 web worker 池,初始化必须放在任何加载之前:

// js/viewer.js —— 页面初始化时第一件事 function initCornerstone() { cornerstoneWADOImageLoader.external.cornerstone = cornerstone; cornerstoneWADOImageLoader.external.dicomParser = window.dicomParser; // loader 4.x 要求 // worker 脚本必须和页面实际路径一致,路径错了就是静默白屏 cornerstoneWADOImageLoader.webWorkerManager.config.webWorkerTaskPaths = [ './lib/cornerstoneWADOImageLoaderWebWorker.min.js' ]; cornerstoneWADOImageLoader.webWorkerManager.initialize({ maxWebWorkers: Math.min(navigator.hardwareConcurrency || 1, 4), startWebWorkersOnDemand: true, taskConfiguration: { decodeTask: { initializeCodecsOnStartup: false, strict: false } } }); }

maxWebWorkers用硬件线程数封顶 4 是个务实的经验值。开满 8 个 worker,首屏那一下确实快,但页面上同时还有检索、缩略图、工具响应在抢主线程,反而会有种说不清的卡顿。startWebWorkersOnDemand: true让 worker 在第一次真正需要解码时才创建,页面打开瞬间不会有一个明显的停顿。initializeCodecsOnStartup: false是为了绕开某些浏览器上 JPEG2000 解码器初始化失败的报错——真遇到需要解 JPEG2000 的序列,第一次解码会慢几百毫秒,属于可接受的代价。

3.4 工具层:滚轮翻片和拖拽调窗是怎么挂上去的

工具注册是一次性的,激活是按需切。demo 里的典型配置是:左键调窗、右键平移、Ctrl+左键缩放、滚轮翻片。

// js/tools.js —— 注册 + 激活 function initTools(element) { cornerstoneTools.init({ showSVGCursor: false }); // 先注册,后激活,顺序不能反 cornerstoneTools.addTool(cornerstoneTools.WwwcTool); // 窗宽窗位 cornerstoneTools.addTool(cornerstoneTools.PanTool); // 平移 cornerstoneTools.addTool(cornerstoneTools.ZoomTool); // 缩放 cornerstoneTools.addTool(cornerstoneTools.StackScrollMouseWheelTool); // 滚轮翻片 // mouseButtonMask:1=左键 2=中键 4=右键,可以按位或组合 cornerstoneTools.setToolActive('Wwwc', { mouseButtonMask: 1 }); cornerstoneTools.setToolActive('Pan', { mouseButtonMask: 2 }); cornerstoneTools.setToolActive('Zoom', { mouseButtonMask: 4 }); cornerstoneTools.setToolActive('StackScrollMouseWheel', {}); // 调窗时用的边界参数,太小会一拖就爆,太大会拖半天没变化 const wwwc = cornerstoneTools.getTool('Wwwc'); wwwc.options.orientationMark = 'right'; }

mouseButtonMask是位掩码,1是左键,2是中键,4是右键。注意浏览器默认右键会弹上下文菜单,需要在容器上contextmenu事件里preventDefault(),否则每次右键平移都会弹一次菜单,体验很差。这也算个经典小坑。

cornerstoneTools.init()的showSVGCursor建议关掉。开着的话每个工具都有一个自定义光标,调试阶段看着还行,正式用起来会遮挡影像边缘。

工具状态是跟着 element 走的,切换序列时不是重新init,而是更新 stack 状态然后cornerstone.invalidate(element)重绘。如果新建了 DOM 节点承载新视口,旧节点上的工具状态和 cornerstone 实例都要清掉,否则会有一层看不见的性能负担——这个在最后一章讲改造时还会碰到。

4. 跑起来:本地起服务、跨域和假数据这三道坎

代码结构清楚之后,落地只剩环境问题。这个项目最容易卡住新人的不是代码,是「打开 HTML 一片空白」。

4.1 环境准备和启动命令

这个项目没有 npm 构建流程,理论上双击index.html就能开——但实际上不行。很多浏览器的file://协议下 XHR 受限、web worker 脚本加载也会被拦。必须起一个静态服务。

# 最省事的本地静态服务,端口随意 cd pacsHtml5dicomviewer python3 -m http.server 8080 # 或者 npx serve -l 8080 .

启动后访问http://127.0.0.1:8080/index.html。首次打开建议先切到本地模式:把config.js里的baseUrl指向./data,同时把pacs.js里的 imageId 拼装换成wadouri:./data/xxx.dcm这种相对路径。这样能先排除掉 PACS 连通性问题,确认渲染链路本身是通的。这个顺序很重要——先证明「能画出来」,再排查「拉不到图」,否则两个问题混在一起,很容易一头雾水。

本地模式验证通过之后,再改回远程地址。改配置前记得把浏览器的缓存关掉(DevTools → Network → Disable cache),否则改了config.js刷新没变化,你会怀疑人生。

4.2 跨域:静态页和 PACS 不同源时的处理

静态服务在 8080,PACS 在 8042,这是两个源,浏览器必然拦。处理方式有两种,取决于你能否动 PACS 那一侧。

能改 PACS 侧的话,直接在服务端加响应头:

# PACS 侧 nginx 配置片段 location /wado { # 允许携带 Range 请求,DICOM 大文件分段取图会用到 add_header Access-Control-Allow-Origin "$http_origin" always; add_header Access-Control-Allow-Methods "GET, OPTIONS" always; add_header Access-Control-Allow-Headers "Range,Content-Type,Accept,Authorization" always; # 不暴露这几个头,前端拿不到 Content-Length,进度条就是空的 add_header Access-Control-Expose-Headers "Content-Length,Content-Range,Accept-Ranges" always; add_header Access-Control-Allow-Credentials "true" always; }

Access-Control-Expose-Headers这一条特别容易漏。不加的话请求本身是成功的,图也能出来,但xhr.getResponseHeader('Content-Length')返回null,加载进度永远是 0%,看起来像卡住了。$http_origin而不是写死*,是因为带上Allow-Credentials之后就不能用通配符了。

动不了 PACS 侧,那就把静态页和 PACS 放到同一个源下——把前端静态文件直接部署到 PACS 服务的某个静态目录里,或者用 nginx 起一个站点,/指向前端目录、/wado指向 PACS 上游。只要最终浏览器看到的 URL 是同一个 host+port,跨域问题就不存在。

提示:改完 CORS 头记得清浏览器缓存并强制刷新,响应头也会被缓存,尤其是带Cache-Control的时候。

4.3 没有 PACS 时怎么造数据

绝大多数人手上没有现成的 DICOM 服务端。两条路:一是项目自带的data/目录里那几个 dcm 文件,直接本地模式跑通;二是自己搭一个最小可用的返回端,用来验证 imageId 拼得对不对、参数有没有传错。

// mock-server.js —— 只做一件事:收到 WADO 请求就回一个 dcm 文件 const http = require('http'); const fs = require('fs'); http.createServer((req, res) => { const url = new URL(req.url, 'http://localhost'); if (url.pathname !== '/wado') { res.writeHead(404).end(); return; } const objectUid = url.searchParams.get('objectUID'); const file = `./data/${objectUid}.dcm`; // 注意这三个头:Type、CORS、Content-Length,缺一个前端就表现异常 res.writeHead(200, { 'Content-Type': 'application/dicom', 'Access-Control-Allow-Origin': '*', 'Content-Length': fs.statSync(file).size }); fs.createReadStream(file).pipe(res); }).listen(8042);

这个 mock 服务的作用不是替代 PACS,而是把「QIDO 查到 UID → 拼 imageId → 请求到文件 → 解码上屏」这条链路里的变量一个个锁死。比如怀疑 imageId 拼错了,就在 mock 里把req.url打出来看一眼,参数对不对一目了然。真接上 PACS 出问题的时候,你已经知道前端这半段是好的,范围一下就缩小了一半。

5. 避坑与排查:五个把 demo 卡死在白屏的坑

下面五条几乎是我每次带人跑这类项目都会遇到的,按「现象 → 原因 → 解决」记。

坑一:页面白屏,控制台只有一句「loadAndCacheImagefailed」

现象是视口区域一片白(或者纯黑),Network 里看不到对/wado的请求。原因通常是cornerstone.enable(element)没执行,或者 element 尺寸是 0。cornerstone.enable会在容器里插一层内部结构,容器如果height: 0,插进去的东西全都看不见,而且 cornerstone 拿不到正确尺寸。解决办法是给视口容器显式定高:#dicom-viewer { width: 100%; height: calc(100vh - 56px); },然后确认enable在displayImage之前调用。CSS 里用flex: 1撑开的那种布局最容易踩到,父容器没高度,子元素的高度就是算出 0。

坑二:web worker 脚本 404,解码永远不返回

现象是 Network 里能看到 dcm 请求成功返回 200,但页面上什么都不出现,Promise 一直挂着。原因是webWorkerTaskPaths配的路径和实际部署路径不一致——./lib/xxx.js是相对当前页面的,如果你的 HTML 在/viewer/子目录下,那个路径就指向了/viewer/lib/。解决办法是打开 DevTools 的 Sources 面板,看 worker 有没有被创建,再直接访问那个路径确认 404。另一个变种是路径对了但 worker 脚本版本和主库版本不一致,表现是 worker 报undefined is not a function,这种就得核对lib/下两个文件的版本号。

坑三:图出来了但滚轮翻片没反应

现象是首张图正常显示,鼠标上下滚纹丝不动,控制台无报错。原因基本是三个之一:addStackStateManager没调用、StackScrollMouseWheelTool没setToolActive、或者工具层和 loader 的大版本不匹配。前两个看代码就能定位,第三个比较玄学——tools 3.x 配 loader 4.x 的时候,工具注册本身不报错,但内部的事件代理链路是断的。解决办法是把 tools 升到 4.x,并且确认init()的调用时机在cornerstone.enable之后、displayImage之前。

坑四:切换序列之后,第二组图窗宽窗位是错的

现象是第一组图调完窗之后再点第二个序列,新图要么全黑要么全白,手动拖一下又能正常。原因是 viewport 是跟着 element 走的,切序列只换了 image 没重置 viewport,上一组图的voi被继承下来了,而不同模态、不同部位的窗值差好几个数量级。解决办法是在换序列的入口统一走一遍「重置 viewport 再设初值」的流程,别指望displayImage帮你重置。批量换序列的地方多的时候,封装成resetViewport(element, image)一个函数调用,省得漏。

坑五:看几十张之后页面越来越卡,最后浏览器标签崩掉

现象是长序列翻到后段明显掉帧,DevTools 的 Memory 面板看内存曲线一路往上不回头。原因是 cornerstone 有缓存池,但缓存上限是独立的,加上工具层的 toolState 每张图都会存一份测量数据,长序列叠加起来很可观。解决办法是给cornerstone.imageCache设一个合理上限(比如 300 张),并在切换序列时主动cornerstoneTools.clearToolState(element, 'stack')和cornerstone.imageCache.purgeCache()。缓存上限不是越大越好,超过显存/内存能承受的量之后,翻片会开始频繁触发淘汰和重解码,反而更卡。

6. 二次开发:把这个 demo 改成能进科室流程的小工具

demo 跑通之后,真正的工作量在「接自己的 PACS」和「加上科室真正要用的两个功能」。我按改造优先级排一下。

第一优先级是把pacs.js里用检查号查序列的部分,换成用患者 ID 或者申请单号查检查列表。这里的表结构调整很轻,但有几个参数必须对:QIDO 的分页参数limit/offset,不传的话某些服务端一次回几千条,页面直接卡死;日期区间参数用00080020(StudyDate)配合includefield,比如?StudyDate=20240101-20240131&limit=50。改造点是:

// 按患者 ID + 日期区间查检查,注意 limit 必须传 async function queryStudies({ patientId, from, to }) { const params = new URLSearchParams({ PatientID: patientId, StudyDate: `${from}-${to}`, // 格式 YYYYMMDD-YYYYMMDD limit: '50', // 不传可能一次回几千条 offset: '0', includefield: '00081030,00080061' // 可选字段:检查描述、模态 }); const resp = await fetch(`${PACS_CONFIG.baseUrl}${PACS_CONFIG.qidoPath}?${params}`, { headers: { Accept: 'application/dicom+json' } }); return resp.json(); }

第二优先级是序列缩略图列表。阅片页左侧竖排一排小图,点哪张跳哪张。实现上不建议每个序列都建一个 cornerstone 视口——十几个视口的内存开销很吓人。常见做法是只给当前序列建视口,缩略图用单独的小图解码一次后转成 dataURL 塞进<img>,或者干脆只显示序号和描述文字,点了再加载。

第三优先级是测量和标注。LengthTool、AngleTool、EllipticalRoiTool加上去很快,难的是持久化——测量数据存在cornerstoneTools.getToolState里,刷新页面就没了。要落到后端,得把 toolState 序列化成自定义结构(或者 DICOM SR),再在加载序列时反序列化回去。这块工作量比看起来大,建议先做「导出为 JSON 下载」,验证整个数据结构跑通,再接后端。

验证改造是否成功,我的习惯是准备三组固定用例:单帧 CR(只有一张,考验没序列的情况)、薄层 CT 三百张以上(考验预取和内存)、带WindowCenter多值的 MR(考验默认窗选取)。每改一处配置就过一遍这三组,比漫无目的地看点开的效果靠谱。

最后说个教训。早期我改这类项目的config.js时习惯直接在原文件上改,改到第三版已经忘了哪一行是原始的、哪一行是我为了调通临时加的,重装环境时对着一堆魔改的常量完全推不出来。从那以后我每次拿到这种 demo,第一件事是把config.js复制一份成config.local.js,index.html里引本地那份,原始文件一个字不动——后来踩坑要回滚,直接把本地那份删了重拷,三秒钟回到出厂状态。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询