摘要:CT 片子不是 JPG,是一个装了两百多个标签的 16 位灰度矩阵。这篇文章讲清楚:浏览器怎么把这种「原始像素」渲染成人眼能看的图像、窗宽窗位到底在算什么东西、为什么解码必须扔进 Web Worker,以及我在实际项目里踩过的工具状态互斥、影像寻址、内存吃满三个坑。全文基于一个只有 373 行业务代码的远程影像会诊模块展开。

一、先把这个场景说清楚

很多同学对「医疗前端」的印象还停留在「做个挂号页面」。真到影像这块,完全是另一个世界。

业务是这样的:基层医院拍完 CT / MR,影像文件上传到服务器;上级医院的医生打开浏览器,在会诊系统里点开这个患者的片子,然后:

  • 鼠标滚轮一滚,在序列里翻下一张;
  • 按住左键拖动,平移图像、看边缘病灶;
  • 拉到最大,用「长度工具」量一个结节直径;
  • 切到「角度工具」,量 Cobb 角;
  • 调「窗宽窗位」,同一个文件瞬间从肺窗变成骨窗;
  • 再点一下「反色」,黑底白图变白底黑图。

整个过程不能装任何客户端,不能依赖院内的 PACS 工作站,就是打开一个网页。

先抛一个数字,它能说明这件事的门槛到底在哪:

这个模块自己写的业务代码只有 373 行(三个 js 文件),同目录下 cornerstone 的库代码有 8000 多行。

所以价值根本不在「写代码」,而在三件事:知道该调哪几个 API、每个参数为什么必须这么传、以及哪些地方库会悄悄坑你。

二、为什么这不是「加载一张图」

普通人以为 CT 片是张 JPG,其实 DICOM 文件里装的是 16 位灰度像素矩阵 加上几百个标签(Tag)。

举个最直观的例子:一台 CT 的像素值范围大概是 -1024 到 3071,一共 4096 个灰阶。而你屏幕能显示的是 0 到 255,256 个灰阶。

直接把 4096 压到 256?那所有软组织全都糊成一片灰,什么也看不出来。

所以医生实际在做的事情是:从 4096 个灰阶里挑一段「我感兴趣的区间」,单独铺满整个 0~255。

  • 想看肺纹理:取 -1000 ~ 100 这一段(肺窗)
  • 想看骨小梁:取 300 ~ 1500 这一段(骨窗)
  • 想看脏器:取 -200 ~ 300 这一段(软组织窗)

这个「取哪一段」的两个参数,就是窗位(Window Center,区间的中心)和窗宽(Window Width,区间有多宽)。

换算公式:

显示值 = (像素值 - (窗位 - 0.5)) / (窗宽 - 1) + 0.5

算出来是 0~1 之间的小数,再乘以 255 就是屏幕灰度。超出这个区间的部分会被钳到 0 或 1,也就是全黑或全白。

这就是为什么调窗宽窗位时图像会「变」:不是在做 CSS 滤镜,是在重新映射像素。 这一层就是 cornerstone 的价值,也是这件事真正的门槛。

再叠上几个真实需求,事情就更复杂了:

  • 一个序列几十上百张(多帧),要能连续翻页;
  • 上面要再叠一层「测量标注」,长度、角度、ROI,还得能拖能删;
  • 影像文件几十 MB,要边下边解边显示;
  • 切工具时鼠标左键的行为要整个换掉。

三、初始化:把四个库挂到同一个「外部依赖」上

cornerstone 生态是分包的,core 只负责渲染,解析、加载、工具都是独立包。它们互相之间不直接 import,而是通过 external 约定注入,这样每个包都能独立发版。

// 影像加载与工具注册(做了注释补充)
cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
cornerstoneTools.external.cornerstone = cornerstone;
cornerstoneTools.external.Hammer = Hammer;               // 手势库,触屏缩放要它
cornerstoneTools.external.cornerstoneMath = cornerstoneMath;
cornerstoneTools.init();                                 // 工具集初始化,只调一次

// 关键:解码放在 Web Worker 里,数量按 CPU 核心数自适应
var config = {
  maxWebWorkers: navigator.hardwareConcurrency || 1,
  startWebWorkersOnDemand: true,
};

这两行 worker 配置,我认为是整个模块最关键的两行。

一张 512×512 的 16 位影像,原始像素就是 512×512×2 = 512KB;一个序列上百张,几十 MB。而且 DICOM 里的像素往往是 JPEG2000 或 JPEG-LS 压缩的,解码是纯 CPU 密集运算。

如果解码放主线程,翻页会直接卡死——不是慢一点,是滚一下要等两秒那种卡死。

按核心数开 worker + 按需启动,是「顺滑翻片」的前提。startWebWorkersOnDemand: true 的意思是别一进来就开满八个线程占内存,等真有图要解了再起。

四、加载与渲染:三步

function display(instanceIndex) {
  coords = $("#coords").val();
  coordArray = $.parseJSON(coords);        // 一个序列的所有 instanceUID

  const element = document.getElementById('demo-element');
  const instanceId = coordArray[instanceIndex];

  // 走 WADO 协议取像素数据,后端提供 /instances/{id}/file
  const imageId = "dicomweb://" + window.location.host + "/instances/" + instanceId + "/file";

  cornerstone.enable(element);             // 把普通 div 变成可渲染影像的 element

  cornerstone.loadAndCacheImage(imageId).then(function (image) {
    cornerstone.displayImage(element, image);
  });

  // 工具注册(每个都要 addTool 才能用)
  cornerstoneTools.addTool(cornerstoneTools.LengthTool);         // 长度测量
  cornerstoneTools.addTool(cornerstoneTools.MagnifyTool);        // 放大镜
  cornerstoneTools.addTool(cornerstoneTools.ZoomMouseWheelTool); // 滚轮缩放
  cornerstoneTools.addTool(cornerstoneTools.AngleTool);          // 角度测量
}

三步:enable(把 div 变成画布容器)→ loadAndCacheImage(拿像素)→ displayImage(渲染)。

注意 loadAndCacheImage 里那个 Cache:cornerstone 自己有 LRU 图片缓存。这是好事(翻回来不用重新解),也是坑(下面第五节说)。

五、三个真正要讲清楚的点

1. 工具是一套互斥状态机,不是四个按钮

这是最容易写错的地方。cornerstoneTools 里,任何时刻只能有一个工具接管鼠标左键。你不能「长度和角度同时激活」,否则左键按下时两个工具都在监听,结果就是画出来两条线。

所以每次切换工具,必须是「先把其他全部禁用,再激活目标」:

function wwwc() {                                  // 调窗宽窗位
  clearTool();
  cornerstoneTools.setToolActive('Wwwc', { mouseButtonMask: 1 });
}

function clearTool() {
  cornerstoneTools.setToolDisabled("Magnify");
  cornerstoneTools.setToolDisabled("ZoomMouseWheel");
  cornerstoneTools.setToolDisabled("Angle");
  cornerstoneTools.setToolDisabled("Length");
  // 注意:这里没有 Eraser / Wwwc —— 实际存在的状态残留
}

这里有个我在代码里抓到的真实 bug:后来版本新增了 Eraser(擦除)和 Wwwc(窗宽窗位)两个工具,但 clearTool() 没有同步补上它们。

结果就是:点了「擦除」之后再点「测量」,擦除工具其实还没关,鼠标一点就把标注擦掉了。

这种「新增功能但忘了改清理函数」的不一致,是真实项目里最常见的一类 bug。它不报错、不崩,只是行为诡异。能一眼看出来,比会背 API 值钱。

2. 反色和窗宽窗位,操作的是 viewport,不是 CSS

很多人第一反应是给 canvas 加 filter: invert(1)。能用,但不对。正确姿势是改渲染参数,让渲染管线重新做一次像素映射:

function invert() {
  clearTool();
  const element = document.getElementById('demo-element');
  var viewport = cornerstone.getViewport(element);   // 取当前视口状态
  viewport.invert = viewport.invert === true ? false : true;
  cornerstone.setViewport(element, viewport);        // 写回,触发重绘
}

viewport 里装的是:缩放比例、平移偏移、窗宽窗位、反色、旋转、翻转。所有「显示层的变换」都在这里,不在 CSS 里。

这个区别能立刻区分「用过库」和「懂渲染」:

  • CSS filter:像素先渲染成图像,再做图形学滤镜,测量标注也会被一起反色;
  • viewport.invert:像素映射阶段就反了,标注层(另画的)不受影响。

同理,窗宽窗位也不要自己算 canvas,交给 setToolActive('Wwwc'),用户按住左键上下左右拖,库会实时改 viewport 的 windowWidth/windowCenter。

3. 测量标注和影像是两层,别试图画在一起

cornerstoneTools 的标注(measurement)是独立存储的,绘制时叠在影像 canvas 上层。这带来两个实际影响:

  • 影像翻页时标注要跟着「当前 instance」走,否则会出现「第 3 张的测量线画在第 5 张上」;
  • 标注数据要能序列化存库(结构化报告),所以别把结果只放在 DOM 里。

六、影像从哪来:两条寻址链路

代码里有两个版本,正好对应两种部署形态,这个设计挺值得讲:

① 院内 WADO 模式

const imageId = "dicomweb://" + host + "/instances/" + instanceId + "/file";

影像存在应用服务器后面,前端只拿 instanceUID,靠 WADO(DICOMweb 的取图标准)去取。适合院内网、数据不出院。

② 云端 OSS 模式

后端返回一个带签名的对象存储 URL(形如 ?OSSAccessKeyId=…&Signature=…&Expires=…),前端拼成 dicomweb:// 直接向 OSS 取流:

// 后端下发的是短时效签名 URL,前端不碰密钥
const imageId = "dicomweb://" + signedUrl.replace(/^https?:\/\//, '');

这一招绕开了应用服务器做影像分发。 影像文件大、并发高、还得支持分段请求(range),全压在后端 Java 服务上迟早出事。丢给对象存储以后,带宽、鉴权、缓存、断点全都交给云厂商,应用服务器只负责「签发一个 5 分钟有效的 URL」。

这是大文件场景的标准做法。

七、内存:第一个被吃掉的东西

cornerstone 的 loadAndCacheImage 会往 LRU 缓存里塞解码后的 Image 对象。一张 512×512×16bit,解码后按 RGBA 存是 512×512×4 = 1MB 左右。

翻 100 张就是 100MB。 再开两个序列、几个 tab,浏览器标签页直接上 G。

实际处理方式:

cornerstone.imageCache.setMaxCacheSize(1024 * 1024 * 200); // 给个上限,比如 200MB

超了就按最近最少使用淘汰。淘汰后翻回去会重新下载 + 重新解码——这是拿时间换内存,影像场景里通常是正确的取舍。

另外一个容易忽略的点:组件卸载时一定要 cornerstone.disable(element),否则 element 上挂的事件监听和渲染循环会一直留着。SPA 里切换页面几十次,内存曲线就上去了。

八、边界

这套东西的真实边界很清楚:

  • 只做 2D 序列阅片,不做 MPR(多平面重建)、不做 3D 体渲染、不做 MIP。那些要么上 cornerstone3D,要么直接调后端重建服务;
  • 不做 AI 辅助诊断,只做「显示 + 测量 + 标注」;
  • 不做影像归档,那是 PACS 的活;
  • 浏览器端再强也算不动大矩阵,真正的重活在前处理和重建。

九、小结:一张清单带走

环节关键点一句话
认知16 位灰度 + Tag不是图片,是矩阵 + 元数据
显示窗宽窗位从 4096 灰阶里挑一段铺满 0~255
解码Web Worker按 hardwareConcurrency 开池,按需启动
交互工具互斥切工具前必须 disable 全部其他工具
变换viewport反色/缩放/平移都改 viewport,不是 CSS
取图WADO / 签名 URL大文件丢给对象存储,别压应用服务器
内存LRU 上限 + disable不留上限就是移动端杀手
标注独立层 + 序列化跟 instance 走,能存库

如果只记一句话:

医疗影像前端的技术含量,不在「会调 API」,而在「知道像素是怎么变成人眼看到的灰度」。


本文是「前端硬骨头」系列第 1 篇。下一篇讲银行项目里的接口安全链路:国密 SM4 加密 + HMAC-SHA256 签名防重放,以及 btoa(hex) 这种能把人坑一整天的细节。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐