简介这是一份基于HTML5与Cornerstone.js开发的PACS DICOM在线阅片演示项目面向医学影像相关开发人员及医疗信息化从业者帮助大家理解如何用浏览器直接加载、浏览和测量DICOM影像无需依赖本地专业软件。资源包共278个文件容量7.46MB以188个JS脚本和40个HTML页面为主涵盖DICOM渲染、工具交互、界面布局等核心模块同时配有18个Markdown说明文档与3个CSS样式文件便于快速梳理项目结构与二次开发。已有2353人学习下载。代码来自Cornerstone Tools示例项目master分支包含缩放、平移、堆栈滚动等交互功能的可视化演示gif动图及配置文件可直接运行体验。对于远程诊疗、科室协同、医学影像教学等Web阅片应用场景有较强参考价值尤其适合具备一定JavaScript基础的开发者上手实践。1. HTML5 DICOM 阅片 DemoPACS 场景下先把浏览器阅片跑通如果你在医疗信息化行业待过一阵一定经历过这种需求某科室主任拿着平板电脑问能不能不装客户端、直接在浏览器里打开 CT 序列看片。传统 PACS 阅片工作站是桌面程序装一台机器要折腾一台换台电脑又要重来一遍。这个 HTML5 DICOM 阅片 Demo 就是把整套链路剪到最小从 DICOM 数据源拉取影像在浏览器端解码、渲染、翻页、调窗宽窗位。它解决的不是替代专业阅片工作站这种宏命题而是让一张 DICOM 影像在浏览器里以可交互方式显示出来这个最基础的落地问题。适合前端工程师、做医疗集成的实施人员以及想快速摸清 DICOM 解析和 PACS 数据接口的人。2. 从 PACS 到浏览器DICOM 数据链路与 HTML5 选型理由2.1 为什么我说浏览器阅片是大趋势桌面插件模式的老账与新账早几年做 Web 阅片最常见的方案是 ActiveX 插件或者 Java Applet。这类方案在当时是唯一出路因为浏览器本身没有能力直接解析 DICOM 的传输语法也没法高效操作像素缓冲区。但插件模式的代价很大IE 版本绑定、证书签名、每次系统升级都要重新适配实施人员到现场装插件的血泪经验做过的都懂。后来 HTML5 标准把 Canvas 2D、WebGL、TypedArray、Web Worker 这些能力补齐了。Canvas 可以直接操作 ImageData 像素数组TypedArray 能高效读写二进制缓冲区Worker 可以把耗时的解码和像素计算放到后台线程。这套组合意味着浏览器端具备了自己处理 DICOM 像素数据的基础能力。再加上医疗系统普遍开始从 C/S 架构转向 B/S 架构影像数据经过网关转成 HTTP 接口输出前端做阅片就成了顺理成章的事。这个 Demo 选 HTML5 而不是插件还有一个现实原因移动端和平板设备的浏览器占比越来越高插件在移动端根本没有生存空间。科室主任拿平板看片这个场景只有纯浏览器方案接得住。2.2 数据通路怎么搭DICOM 协议与 HTTP 接口的边界要理解浏览器阅片的数据链路先得明白一个关键边界PACS 系统内部走的是 DICOM 协议默认端口是 104基于 TCP 的 C-STORE、C-FIND、C-MOVE 等操作而浏览器只能发 HTTP 请求不能直接建立 DICOM 协议连接。所以中间必须有一层网关把 DICOM 协议翻译成 HTTP 接口。现代 PACS 或中间件一般会提供 DICOMweb 这类 RESTful 接口其中最常用的是 QIDO-RS查询和 WADO-RS取影像。我一般会先确认测试环境有没有开放这套接口没有的话就找一个小工具把 DICOM 文件直接映射成静态资源先用假数据把前端跑起来。对比项DICOM 协议C-STORE/C-FINDDICOMwebQIDO-RS/WADO-RS传输层TCP 104 端口HTTP/HTTPS浏览器直接访问不行可以查询方式C-FIND 复杂请求RESTful GET取影像方式C-STORE 推送 / C-MOVEGET 返回 DICOM 或 JSON适用场景PACS 内部互联Web 端集成下面是一段按 DICOMweb 约定查询某个 Study 下序列列表的示例把这层接口的样子看清楚// 查询某一 Study 下的 Series 列表QIDO-RS 风格 async function querySeries(studyUID, serverBase) { const url ${serverBase}/studies/${encodeURIComponent(studyUID)}/series; const resp await fetch(url, { headers: { Accept: application/dicomjson } }); if (!resp.ok) { throw new Error(series query failed: ${resp.status}); } const json await resp.json(); return json.map(item ({ seriesUID: item[0020000E].Value[0], modality: item[00080060]?.Value[0] ?? OT, instanceCount: item[00201209]?.Value[0] ?? 0 })); }这段代码最需要注意的地方是 Accept 头必须写成application/dicomjsonPACS 网关靠这个头判断你要的是 DICOM 格式的 JSON 元数据返回的是 DICOM 标签嵌套结构。0020000E是 Series Instance UID 的标签号00080060是成像模态00201209是序列内实例数量。取回来的 UID 要给下一步拉取具体影像用。如果测试环境没有 DICOMweb 网关常见做法是直接用本地静态 DICOM 文件模拟。把一个 DICOM 文件丢到静态服务器目录下前端用 fetch 拿整个文件回来解析效果一样只是跳过了网络接口层。3. 核心渲染链路从 DICOM 像素到 Canvas 灰度图3.1 读懂 DICOM 文件结构像素数据在哪里传输语法为什么是第一个坎DICOM 文件结构说复杂很复杂说简单也简单文件头128 字节 preamble 4 字节 DICM 标识后面跟着一组数据集每个数据集都是一个 tag8 位组标签 VR值表示法 长度 值。真正需要关心的 tag 很集中0028,0010行数、0028,0011列数、0028,0100像素位数、0028,0101实际存储位数、0028,0103像素表示0 无符号、1 有符号、0028,1050窗宽、0028,1051窗位、7FE0,0010像素数据。但文件结构只是第一关真正的坎是传输语法。DICOM 的像素数据可能是未压缩的原始像素也可能是 RLE、JPEG-LS、JPEG2000、JPEG-Lossless 等压缩格式。未压缩的直接按字节读就行压缩的就需要对应解码器。这个 Demo 里我只实现了未压缩和部分 RLE 传输语法的解析遇到 JPEG2000 这类格式直接抛错提示服务端转码不做硬解。怎么判断传输语法看文件元信息里的0002,0010Transfer Syntax UID。常见 UID 对应关系如下Transfer Syntax UID含义Demo 是否支持1.2.840.10008.1.2隐式 VR 未压缩支持1.2.840.10008.1.2.1显式 VR 未压缩支持1.2.840.10008.1.2.5RLE 压缩部分支持1.2.840.10008.1.2.4.90JPEG2000不支持提示转码1.2.840.10008.1.2.4.70JPEG-LS不支持提示转码3.2 像素值转灰阶窗宽窗位公式与第一版渲染代码拿到7FE0,0010像素数据后不能直接塞进 ImageData 显示。一张 CT 的像素值通常是 12 位或 16 位范围可能到几千甚至上万而屏幕只有 8 位灰度0-255。如果直接把原始值除以最大值归一化低对比度的软组织区域会糊成一片这就是为什么必须有窗宽窗位映射。映射公式是gray (pixel - center) / width * 255 127.5然后裁剪到 0-255。实际代码里我会统一写成// 根据窗宽窗位把像素值映射到 0-255 灰度 function renderFrame(pixelData, rows, cols, modLut) { const low modLut.center - modLut.width / 2; const high modLut.center modLut.width / 2; const range high - low; const imageData ctx.createImageData(cols, rows); const data imageData.data; for (let i 0; i rows * cols; i) { let value pixelData[i]; if (modLut.signed value 32768) value - 65536; let gray ((value - low) / range) * 255; gray Math.max(0, Math.min(255, gray)); const idx i * 4; data[idx] gray; data[idx 1] gray; data[idx 2] gray; data[idx 3] 255; } ctx.putImageData(imageData, 0, 0); }参数说明modLut.center和modLut.width对应 DICOM 标签0028,1050和0028,1051如果文件里没写这两个值就得按模态给默认预设比如 CT 头部默认窗宽 80 窗位 40腹部默认窗宽 400 窗位 40。modLut.signed对应0028,0103为 1 时像素是有符号数需要做符号扩展否则负值会被读成很大的正数渲染出来一片花白。注意这段逻辑是按未压缩像素写的压缩数据要在上一层先解成原始像素数组再传入。4. 阅片交互序列翻页、窗宽窗位调节与预加载策略4.1 序列的组织Study-Series-Instance 三层结构DICOM 数据的组织层级是 Study检查、Series序列、Instance实例。一次 CT 检查是一个 Study里面可能包含平扫、增强等多个 Series每个 Series 里有几十到上千张断层图像每一张是一个 Instance。前端拿到这些数据后通常先展示 Study 列表点进去看 Series 列表再进 Series 开始阅片。序列内翻页看起来简单但有一个坑不能按文件名或 Instance Number 排序。同一序列的断层图像DICOM 标准要求按0020,0032Image Position Patient 排序也就是每张图像的实际空间位置。如果不按位置排翻页时影像位置会跳来跳去尤其在做矢状面重建时层序错乱是致命的。// 拉取序列实例列表并按空间位置排序 async function loadInstances(serverBase, studyUID, seriesUID) { const url ${serverBase}/studies/${studyUID}/series/${seriesUID}/instances; const resp await fetch(url, { headers: { Accept: application/dicomjson } }); const json await resp.json(); const instances json.map(item ({ instanceUID: item[00080018].Value[0], instanceNumber: parseInt(item[00200013]?.Value[0] ?? 0, 10), position: parseFloat(item[00200032]?.Value[2] ?? 0) // Z 轴坐标 })); instances.sort((a, b) a.position - b.position); return instances; }这段代码里00200018是 SOP Instance UID00200013是 Instance Number00200032是 Image Position Patient它的第三个数就是断层位置的 Z 轴坐标。按 position 排序比按 instanceNumber 可靠因为有些设备导出的 Instance Number 不连续。翻页时只需要维护一个currentIndex切换当前帧索引重新走一遍渲染逻辑就行。4.2 窗宽窗位与缩放平移的交互实现窗宽窗位调节的本质是不重新解码像素只重新做一次像素到灰阶的映射。前面渲染那一层耗时的操作是像素数据读取和格式解析映射本身只是几百万次加减乘除加裁剪现代机器上几十毫秒就能跑完。所以交互响应速度通常不是问题问题在于实现时有没有把解码和映射两步分开。// 两个滑动条改变窗宽窗位触发重新映射 let currentModLut { center: 40, width: 400 }; const wwSlider document.querySelector(#ww-slider); const wcSlider document.querySelector(#wc-slider); function onWindowLevelChange() { currentModLut.width parseInt(wwSlider.value, 10); currentModLut.center parseInt(wcSlider.value, 10); renderFrame(originalPixelData, rows, cols, currentModLut); // 原始像素缓存重映射 } wwSlider.addEventListener(input, onWindowLevelChange); wcSlider.addEventListener(input, onWindowLevelChange);这里必须用input事件而不是change事件change要等松开鼠标才触发拖动过程中画面不会实时更新这是新手最容易犯的错也是拖了没反应的常见原因。原始像素数据要用单独变量保存不要在重映射时重新 fetch 或重新解码。缓存策略上originalPixelData是序列所有帧共享的一份 ArrayBuffer 视图改窗宽窗位只重算 ImageData。缩放和平移实现时要注意坐标参考系。缩放我一般用ctx.scale(scale, scale)配合ctx.translate(offsetX, offsetY)但要注意 putImageData 是不受 transform 影响的必须先把变换后的 ImageData 画到一个离屏 Canvas再 drawImage 到主 Canvas否则会出现放大后图像边缘被裁剪的情况。更简单的方式是手动改 ImageData 的像素尺寸再 put但对大序列来说内存开销高一倍我倾向用离屏 Canvas 方案。4.3 预加载与内存控制的常见做法DICOM 单张图像可以到几兆字节甚至更大一个序列几十上百张全部下载并解码内存直接爆炸。常见做法是做窗口预加载当前帧前后各预取 N 帧缓存中维护一个 LRU超出上限的请求取消释放。// 预加载当前帧附近的 N 帧 async function preloadAround(current, total, cache, fetchInstance, limit 6) { const start Math.max(0, current - limit); const end Math.min(total - 1, current limit); for (let i start; i end; i) { if (cache.has(i)) continue; const controller new AbortController(); cache.set(i, { controller, promise: fetchInstance(i, controller.signal) }); } // 超出上限的提前释放 if (cache.size 30) { for (const [key, item] of cache) { if (key start || key end) { item.controller.abort(); cache.delete(key); } } } }这段代码的关键点是每个请求都绑了一个 AbortController窗口滑过后的请求直接 abort释放网络连接和解码内存。limit参数控制预加载范围我一般取 6 左右网络好时可以加到 10但要注意移动端带宽预加载范围太大会抢占主帧的下载带宽反而卡顿。缓存上限设 30 是为了防止整个序列被撑爆实际项目中按单帧大小调整。5. 阅片 Demo 避坑清单五条能救命的排查记录5.1 现象一打开一片黑连白底灰线都没有现象DICOM 文件加载了接口返回 200Canvas 上却是一片纯黑。 原因最常见的是传输语法不支持。文件像素数据实际是 JPEG2000 或 JPEG-LS 压缩解码逻辑不认识返回空数组其次是像素位数读错把 16 位数据按 8 位读值全部变成 0。 解决第一步在控制台打印0002,0010Transfer Syntax UID对照支持的 UID 列表。不支持的要么引入对应解码器要么在服务端做统一转码输出未压缩像素。像素位数读错的话检查0028,0100和0028,0101按 Bits Allocated 而非 Bits Stored 分配缓冲区。5.2 现象二窗宽窗位调节没反应现象拖动滑动条影像纹丝不动。 原因事件绑定用了change而不是input拖动过程中没触发或者渲染函数里又去重新请求了一遍原始像素数据每次回调拿到的是新 Promise视觉上像延迟而不是没反应。 解决确认事件类型是input修改状态后直接基于缓存的原始像素数组做映射不要重新走网络。这里还有一个隐藏点很多实现把窗宽窗位调节和缩放平移都挂在同一个重绘函数里如果缩放平移参数没初始化就会整体报错后面所有交互全部失效。5.3 现象三连续翻页卡成幻灯片现象快速点击翻页画面明显卡顿甚至浏览器标签页崩溃。 原因每翻一页都同步重新下载整个 DICOM 实例并在主线程解码网络和 CPU 都堵在一条道里。 解决按 4.3 的思路做预加载和解码分离。下载用异步 fetch解码放 Web Worker主线程只负责把解码后的像素数据映射到 Canvas。如果临时不想引入 Worker至少做到当前帧先显示下一帧在空闲时解码而不是等所有帧全部就绪才渲染。5.4 现象四HTTPS 页面加载 HTTP 接口被浏览器拦现象同一套代码本地联调正常部署到生产环境后影像完全加载不出来控制台提示请求被标记为不安全内容。 原因页面是 HTTPS而 PACS 网关走的是 HTTP浏览器默认拦截混合内容。这个问题在 PACS 场景里非常隐蔽因为网关往往是医院内网的老设备改造 HTTPS 周期很长。 解决如果 PACS 网关无法快速升级可以在公网入口做一层 HTTPS 反代让浏览器看到的请求路径全部是同源的 HTTPS由代理转发到内网 HTTP。不要试图绕开浏览器安全策略这个方向只会浪费更多时间。5.5 现象五同一序列的切片顺序错乱现象翻页时影像的空间位置跳来跳去尤其在做三维重建或看连续断层时明显。 原因按 Instance Number 或者文件名排序。不同厂商的 DICOM 导出工具对 Instance Number 的处理不一致断号、重号都有。 解决统一按0020,0032Image Position Patient 的 Z 轴坐标排序。如果某些 DICOM 文件没有这个 tag再用0020,1041Slice Location 兜底两个都没有就直接按 Instance Number 并加日志告警说明数据源本身不规范。6. 进阶验证接入真实 PACS 前先做这三步6.1 准备一份私有 DICOM 样本集做回归开发阶段最常见的翻车现场是拿一个文件测试通了就以为万事大吉。真实 PACS 里一个序列可能有几十种传输语法、多种模态混合。我会先准备一份样本集至少包含一个未压缩 CT 序列、一个 RLE 压碎的 DR 图像、一个没有窗宽窗位标签的数据。没有现成样本的话可以找一台测试设备导出少量脱敏数据或者用开源测试数据集生成工具造一批合成图像。注意不要用真实病人的完整影像做开发验证字段脱敏但 tag 结构完整的测试数据足够用。6.2 在端到端联调前固定三个参数联调之前把三个参数固定下来传输语法白名单、跨域来源、并发上限。如果你控制的网关只输出未压缩和 RLE前端解码范围就按这个白名单走。跨域来源写成页面实际部署的域名加端口不要用*某些网关对*处理有兼容问题。并发上限指同一时刻允许发起的 WADO 请求数我一般设为 4 到 6测试网关的吞吐能力后再调。6.3 像素级对比用灰度直方图验证渲染结果一个容易忽略的验证步骤是确认 Demo 渲染出来的像素值与参考阅片软件一致。做法是取同一帧、同一窗宽窗位条件下对比两边的灰度直方图分布。可以直接在 Demo 里加一段计算灰度直方图的代码// 统计渲染后灰度值分布用于和参考软件对比 function buildHistogram(pixelData, bins 256) { const hist new Array(bins).fill(0); for (const v of pixelData) { const index Math.max(0, Math.min(bins - 1, Math.round(v))); hist[index]; } return hist; }我一般会在控制台打十来个抽样点的直方图桶数据和参考软件的直方图目测对比形状一致就说明窗宽窗位映射和符号处理没有系统性偏差。别小看这一步像素符号处理错了一个负号画面看起来可能还挺自然直方图一对就露馅。从那以后我每次接入新的 PACS 环境都强制走一遍样本回归、参数固定、直方图对比这三步省下的排查时间远超做事的时间。希望帮到你。本文还有配套的精品资源点击获取