简介:面向医学影像系统开发者,PostDICOM Cloud PACS API 资源包提供了一套基于云的 DICOM 存档与在线查看方案,支持 MRI、CT、超声、射线等设备图像的存储、访问与共享,有效替代传统硬件 PACS,降低归档成本。压缩包共 5 个文件,包括 2 张 API 运行截图、1 份 LICENSE、1 份 README.md 说明文档和 1 个 reference-implementation.html 参考实现,整体仅 248KB,内容精简,适合快速评估。通过配套 Cloud API 文档,开发者可了解上载、搜索、查看和删除 DICOM 图像及临床文档的接口调用方式;结合页面截图与示例 HTML,能直观把握在线 DICOM Viewer 的界面效果与嵌入现有软件的自定义思路。已有 353 人学习下载,适合正在评估云 PACS 集成方案或进行医学影像二次开发的工程师参考。
1. PostDICOM 不是又一个 DICOM Viewer:它是把阅片能力搬上云的完整 API
很多团队第一次接触 cloud-pacs-api 或 PostDICOM,是卡在"远程会诊"这个需求上。电话里问完病史,对方问你要影像,你只能导出一个 dcm 压缩包发过去,或者让对方到医院拷贝。PostDICOM 这个带在线 DICOM Viewer 的 Cloud PACS 方案,解决的就是这件事:它把 PACS 的存储、检索、分发能力收进一组 API,再配一个浏览器直接打开的阅片界面。别人拿到一个链接就能看序列、调窗宽窗位、翻页,不需要装任何读片软件。它不是一个孤立的 viewer 组件,而是把归档、检索、浏览、权限串起来的一整套云服务,适合影像科信息化团队、医疗集成商,以及做放射辅助诊断软件的小团队拿来当底座。
2. 为什么选 Cloud PACS 而不是传统 PACS:架构判断与三个关键决策
2.1 传统 PACS 的扩展性瓶颈:从院区一体机到多租户
传统 PACS 的典型形态是一台服务器挂磁盘阵列,跑在院内网里,阅片靠安装客户端或者 Citrix 发布远程桌面。单院区、几十台设备、两三年积累几个 TB 数据,这套架构完全够用。但一旦出现三个条件,它就开始别扭:多院区要共享影像、外部医生要远程阅片、系统要开放 API 给第三方做二次开发。
我接触过不少想改造老 PACS 的团队,最普遍的痛苦是扩容要停机。盘阵满了,加柜子要么业务窗口外操作,要么干脆再买一套系统做双存储。更麻烦的是阅片端,C/S 客户端每台电脑都得装,版本升级要在全院跑一遍安装脚本。到了需要对外提供服务的场景——比如医联体下面的社区医院要读上级医院的片子——院内系统连端口都不好开,更别提做权限隔离。
云化之后这些问题被拆成了三个可独立扩展的层:接入层只管协议、索引层只管数据检索、存储层只管像素文件。PostDICOM 这类项目把这三层打包成 API 向上暴露,对一个只有几个人的小团队来说,等于省掉了从零设计分布式 PACS 的试错过程。
2.2 存储与索引分离:对象存储加元数据库的组合
Cloud PACS 和传统 PACS 在架构上最大的差别,是把"文件"和"索引"拆开了。传统 PACS 虽然也有数据库,但像素文件还是落在本地磁盘,检索服务和文件存储被绑定在同一台机器上。云方案里常见的做法是:DICOM 引擎负责协议接入,DICOM 文件的元数据(PatientName、StudyDate、Modality、SeriesInstanceUID 这些 tag)解析后写进 PostgreSQL,像素数据则转成对象存储对象,放进 MinIO 或者 S3。
这个拆分带来的最直接好处是检索不再碰大文件。一份 CT 检查动辄几百 MB 甚至上 GB,但它的元数据只有几十个字段。查"最近一周的头部 CT",传统 PACS 可能要扫存储索引甚至触盘,而对象存储加元数据库的方案里,PostgreSQL 秒级返回 study 列表,随后才按需从对象存储拉帧。存储扩容也变成纯粹的资源操作:往 MinIO 里加节点就行,不需要迁移数据库。
文件命名上我很建议直接用 DICOM 的三个 UID 拼对象键,比如 studies/{StudyInstanceUID}/series/{SeriesInstanceUID}/instances/{SOPInstanceUID}。不要用自增 ID,也不要存一层"文件路径"映射。原因很简单:DICOM 的 UID 本身就是全局唯一键,拿它当对象键等于省掉一张映射表,而且 Orthanc、dcm4chee 这类引擎做导出对接时,都认这个结构。对象存储的目录层级也不要太深,超过三级对部分 S3 兼容网关的性能有影响。
2.3 DICOM 协议在云端的取舍:DIMSE 还是 DICOMweb
接入层是最容易暴露"这是老系统思维还是云思维"的地方。传统 DICOM 网络通信基于 DIMSE(比如 C-STORE、C-FIND),走 TCP 11112 端口,二进制协议,客户端要专门实现 DICOM 协议栈。浏览器里跑不了这玩意,防火墙也默认不放开 11112。而 DICOMweb 标准把同样的能力搬到了 HTTP 上:QIDO-RS 做检索、WADO-RS 取影像、STOW-RS 上传,走 443 端口,JSON 加二进制,浏览器原生就能消费。
下表是两者在云场景下的关键差异:
| 对比维度 | DIMSE(传统) | DICOMweb |
|---|---|---|
| 默认端口 | 11112 | 443 / 80 |
| 传输格式 | 二进制 TCP | HTTPS + JSON / MIME |
| 浏览器直接调用 | 不支持 | 支持 |
| 防火墙穿透 | 难,需开专有端口 | 与普通 Web 一致 |
| 典型操作 | C-STORE / C-FIND / C-MOVE | STOW-RS / QIDO-RS / WADO-RS |
| 权限控制 | 依赖 AE Title 白名单 | 可复用 OAuth / JWT |
混合部署时我一般这样设计:院内保留一个 DICOM 网关,设备把影像通过 DIMSE 推到网关(因为大多数影像设备只认 11112),网关再做内部转换,以 DICOMweb 方式把数据写入云端存储。对外的 API 全部走 DICOMweb 语义。PostDICOM 的定位说白了就是一个把两种协议粘起来的服务层:对内它的 DICOM 引擎监听 storescp,对外它把 QIDO-RS / WADO-RS 包装成更贴近业务的 REST 接口。这个取舍能省掉大量与 CT、MR、DR 设备厂商讨价还价的力气。
3. 把 cloud-pacs-api 跑起来:最小可用的部署路径
3.1 服务组件与端口规划
先看整体拓扑。PostDICOM 这类方案最少需要五个服务:PostgreSQL 存元数据、MinIO 存像素文件、一个 DICOM 引擎做协议接入、一个 API 服务做业务包装、一个 Web 前端托管 viewer 页面。我把它们的关系和端口列在下表,方便你对照排查问题:
| 服务 | 职责 | 默认端口 |
|---|---|---|
| postgres | 存储 study / series / instance 元数据 | 5432 |
| minio | 存储 DICOM 像素对象 | 9000 / 9001 |
| orthanc | DICOM 引擎,监听 DIMSE 与内部 DICOMweb | 11112 / 8042 |
| postdicom-api | 业务 API,QIDO-RS / WADO-RS 聚合层 | 8080 |
| postdicom-viewer | Nginx 托管的静态前端,含 Cornerstone 渲染 | 3000 |
3.2 docker-compose 起一套本地环境:最小命令
下面这个 docker-compose 片段是我搭过的最小可用集合,你直接存成文件就能起。注意 API 和 Viewer 两个服务需要你自己仓库构建镜像,各团队命名不同,这里用postdicom-api:latest和postdicom-viewer:latest指代。
services: db: image: postgres:16-alpine environment: POSTGRES_DB: pacs POSTGRES_USER: pacs POSTGRES_PASSWORD: pacs_secret volumes: - pg_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U pacs"] interval: 5s retries: 10 storage: image: minio/minio:latest command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin123 ports: - "9000:9000" - "9001:9001" volumes: - minio_data:/data orthanc: image: osimis/orthanc:24.8.4 depends_on: db: condition: service_healthy ports: - "11112:11112" - "8042:8042" volumes: - ./orthanc.json:/etc/orthanc/orthanc.json:ro api: image: postdicom-api:latest ports: - "8080:8080" environment: PACS_DB_DSN: postgres://pacs:pacs_secret@db:5432/pacs?sslmode=disable PACS_STORAGE_ENDPOINT: http://storage:9000 PACS_STORAGE_BUCKET: dicom-files ORTHANC_INTERNAL_URL: http://orthanc:8042 ORTHANC_INTERNAL_USER: orthanc ORTHANC_INTERNAL_PASSWORD: orthanc_secret viewer: image: postdicom-viewer:latest ports: - "3000:80" environment: API_BASE_URL: http://localhost:8080起服务就一条命令:docker-compose up -d。它帮你把 11112 暴露给院内设备,把 8080 暴露给外部 Web 调用;MinIO 的 9001 只用于管理控制台,生产环境不要对外。默认情况下 PostgreSQL 和 Orthanc 都不需要直接暴露到宿主,服务间走 Docker 网络即可。
3.3 用 storescu 推入第一份 CT:验证 DICOM 链路
环境起来以后,先用一份现成的 DICOM 文件验证引擎是否在正常工作。我最常用的是 dcm4che 工具包里的 storescu,它是纯命令行,也是网上那些"dicom 发送工具免费版" GUI 小程序的底层内核,直接拿来用最省事:
# 向本机的 Orthanc DICOM 端口发送一份检查 storescu -v \ -aet TESTSCU \ -aec POSTDICOM \ --connect-timeout 10 \ 127.0.0.1 11112 \ /path/to/ct_slice.dcm参数里-aet是发起方 AE Title,-aec是目标 AE Title,Orthanc 的配置里要提前把TESTSCU加进DicomModalities之外,还要在DicomAet里声明自己叫POSTDICOM。--connect-timeout 10是防止设备端不响应时卡死终端。推完后日志里出现Store-Instance成功字样,就说明 DICOM 链路通了。如果报Peer aborted,九成是 AE Title 不匹配;报No acceptable presentation context,则是传输语法协商失败,换个 JPEG 无损传输语法试试。
3.4 在线 Viewer 的访问链路:从 API 到浏览器渲染
推完数据后,浏览器打开http://localhost:3000,输入 API 地址,study list 应该能看到刚推入的检查。这条链路是:Viewer 前端先请求 API 服务的GET /dicomweb/studies?PatientName=...拿 study 列表,然后按 series 维度请求GET /dicomweb/series/...,最后用GET /dicomweb/instances/.../frames/...逐帧拉图像。用 curl 直接打一遍 QIDO-RS,确认 API 层返回正常:
curl -u demo:demo \ "http://localhost:8080/dicomweb/studies?limit=10" \ -H "Accept: application/dicom+json"返回体里应该能看到 StudyInstanceUID、Modality、StudyDate 这些字段。如果返回空数组,先回容器看 Orthanc 里有没有数据,它的 REST 接口http://localhost:8042/instances能直接列出已归档实例;如果 Orthanc 有而 API 没有,基本是 API 服务连 Orthanc 的账号密码配错了,进 api 容器看日志比猜快。
4. DICOM 数据流与 API 设计:第三方系统怎么接入 PostDICOM
4.1 三种接入方式:DICOM 推送、REST 上传与文件导入
PostDICOM 的价值不只在于自家 Viewer 能用,更在于第三方系统能通过明确定义的 API 把影像送进来、取出去。常见落地场景里接入方式有三种,我按部署时的优先级排一下:
| 接入场景 | 技术路径 | 适用对象 |
|---|---|---|
| 影像设备主动推送 | 设备配置 DICOM 端口 → Orthanc C-STORE 接收 | CT / MRI / DR 设备 |
| 业务系统统一定阅上传 | STOW-RSPOST /dicomweb/studies | HIS / 体检系统 / 集成平台 |
| 离线文件批量导入 | Orthanc RESTPOST /instances | 历史光盘数据 / 备份恢复 |
选择上我有个倾向:凡是设备能直连的场景,优先走 DICOM 端口,因为设备端的推送逻辑最成熟;凡是自家业务系统上传,走 STOW-RS,可以直接附带业务上下文信息;凡是历史归档,走 Orthanc 的 REST 批量导入,它支持 zip 包解压导入,一条请求能灌几百个文件。
4.2 检索参数细节:QIDO-RS 的 limit 与 includefield
很多团队接 QIDO-RS 栽在分页和字段上。QIDO-RS 的limit和offset不是服务端默认给够的,你需要主动设置,而且不同实现的最大上限不一样。我在 PostDICOM 里默认限制limit最大 200,超出直接 400,防止前端一次性拉几万条把 API 打崩。加includefield可以控制返回字段,默认只返回 StudyInstanceUID 和必填类型字段,避免响应体过大:
curl -u demo:demo \ "http://localhost:8080/dicomweb/studies?PatientName=张&Modality=CT&limit=50&offset=0&includefield=StudyDescription&includefield=NumberOfSeries" \ -H "Accept: application/dicom+json"这里的PatientName是模糊匹配,多数实现按包含匹配,不是精确等于;Modality则是精确匹配。includefield是查询参数里可重复的,每加一个字段多返回一个 tag,别一次塞几十个,响应体会显著变大,前端解析也慢。
4.3 拉帧与缩略图:WADO-RS 的 Accept 选择与传输语法
Viewer 拉图最容易出问题的地方在 Accept 头。application/octet-stream拿原始帧,image/jpeg拿服务端转码后的 JPEG。云端场景里我强烈建议按需切换:缩略图用image/jpeg,正文阅片用application/octet-stream,并且给 API 服务配置好传输语法转换能力。原因是很多设备推上来的原始帧是 JPEG-LS 或 JPEG 2000,而浏览器对这两种格式的原生解码支持参差不齐,服务端做一次转码能省掉前端大量兼容工作。
# 取第 1 帧,原始字节流 curl -u demo:demo \ "http://localhost:8080/dicomweb/studies/1.2.840.113.1.1/series/1.2.840.113.2.1/instances/1.2.840.113.3.1/frames/1" \ -H "Accept: application/octet-stream" \ -o frame1.dcm # 取第 1 帧,JPEG 转码输出 curl -u demo:demo \ "http://localhost:8080/dicomweb/studies/1.2.840.113.1.1/series/1.2.840.113.2.1/instances/1.2.840.113.3.1/frames/1" \ -H "Accept: image/jpeg" \ -o frame1.jpg多条frames参数可以直接拼,/frames/1,2,3,4能一次取多帧,网络往返时间可以降到原来的四分之一。帧传输的细节这里有个建议:整个 WADO-RS 响应要做缓存,同一个 instance 的帧在不同时间被反复读取是常态,在 PostDICOM API 层加一层简单的 LRU 内存缓存,命中率通常能到 70% 以上,内存成本比数据库查询低得多。
4.4 权限边界:多科室多租户的 series 级隔离
云 PACS 一旦对外开放,权限就绕不开。最粗颗粒度的权限是 user 级,只控制"能否登录系统",这在多院区场景下完全不够。我通常会拉三层权限模型:租户(tenant)→ 科室(department)→ 数据范围(series / study)。每个 study 在归档时写入一行归属记录,API 层在 QIDO-RS 查询里强制追加归属过滤条件,用户传什么查询条件都拦不住这层过滤。
这样设计的原因是 DICOM 的 tag 里没有"科室归属"这种字段,必须在业务侧维护一张映射表。实践上我见过两种:一种是在元数据表上直接加 tenant_id 和 department_id 列,查询时走索引;另一种是单独一张 study_acl 关联表,适合一个 study 被多科室共享的场景。两种我都试过,小规模系统直接加列最简单,遇到跨科室共享的需求后再迁移关联表不迟。权限中间件在 API 层统一做,不要在 Viewer 前端做,前端的控制只是用户体验,不是安全边界。
5. cloud-pacs-api 落地避坑:Viewer 翻车的四个真实原因
5.1 浏览器解不开压缩帧:花屏背后是 JPEG-LS
现象:同一份 CT,在院内工作站读图正常,浏览器里打开序列花屏、全绿或直接黑屏。
原因:设备厂商为了省带宽,推送上来的原始帧用了 JPEG-LS 压缩。JPEG-LS 在 Chrome、Firefox 里的原生解码支持一直不稳定,而大多数 Viewer 引擎(包括基于 Cornerstone 的)对 JPEG-LS 依赖 WASM 解码器,加载失败就静默退回到不支持状态。
解决:在 DICOM 网关或 API 层做传输语法转码,把 JPEG-LS 统一转成 JPEG 无损或 JPEG 2000。具体做法是启用 Orthanc 的TranscodeDicomProtocol配置,将入站传输语法限制为 1.2.840.10008.1.2.4.70(JPEG 无损)或 1.2.840.10008.1.2.4.90(JPEG 2000)。转码会消耗 CPU,4 核机器并发转码别超过 6 路,需要时再考虑横向扩容。
5.2 大序列把内存吃满:预取与缓存设了一刀切
现象:加载一个 2000 帧的 CT 序列,浏览器标签页直接崩,或者 API 容器内存飙升到接近上限。
原因:Viewer 默认把序列所有帧一次性拉完,或者 API 层的缓存策略对大实例的帧数没有限制。WADO-RS 的批量取帧frames/1,2,3,4很方便,但一次请求几百帧就是灾难。
解决:帧预取数量按序列大小动态调整。小于 100 帧预取全部,100 到 500 帧预取中间 50 帧加当前帧前后 20 帧,超过 500 帧只预取当前帧邻近 40 帧。API 层缓存上限也要按字节数设置,我一般设 256 MB,超限按 LRU 淘汰;宁可重复拉帧,不能内存溢出。
5.3 study date 少一天:UTC 存储与本地时区
现象:检查时明明是 5 月 12 日上午,系统里查询StudyDate匹配 5 月 12 日查不到,只匹配 5 月 11 日。
原因:DICOM 的StudyDate是设备本地日期,原本不带时区。数据库把 DICOM 日期解析后按 UTC 存储,东八区的上午被减成了前一天的 UTC 时间。查询时如果也按 UTC 转换,就会整体偏移一天。
解决:元数据入库时把日期分成两列存:原始字符串StudyDate和设备时间StudyTime,业务查询日期时不要做时区换算,直接用原始字符串匹配。只在需要"今天、最近一周"这种相对时间查询时才转成 UTC 范围边界。这个坑在传统 PACS 里不明显,因为院区全在同一个时区,云端一跨地域就暴露。
5.4 跨地域阅片帧加载慢:串行请求是元凶
现象:在宽带上百兆的网络里,帧加载却要等很久,浏览器里每一帧都在"转圈"。
原因:Viewer 默认用 HTTP/1.1 拉帧,浏览器对同一域名的并发连接数限制是 6 个,而帧请求如果按顺序逐帧发出,后面所有帧都在排队。更隐性的是,API 层如果没开 HTTP/2 或没做帧级并发控制,整个链路就变成了串行队的木桶理论。
解决:生产环境把所有对外流量收敛到一个 HTTPS 域名下,开 HTTP/2;帧请求用并发池控制在 6 到 8 个并发,不要超过 10 个,否则 API 的转码线程会被打满。然后在 API 层加一个简单的帧预取接口:Viewer 请求第一帧时,API 直接把后 30 帧的 URL 返回给前端,前端静默拉取。这个技巧能把连续翻页的感知延迟降到接近零。
6. 进阶:把在线 Viewer 嵌入现有系统,并调出"能接受"的阅片手感
6.1 iframe 与 SDK:嵌入方式的取舍
第三方系统接入在线 Viewer,最短路径是 iframe:<iframe src="http://viewer.example.com/studies?studyUID=...">,但对 API 的细粒度控制、错误捕获会受限。我更倾向于用 SDK 方式:以 Cornerstone3D 为渲染核心,自己封装一个 Study 加载组件,通过 PostDICOM 的 WADO-RS 拿帧,每帧的加载进度、失败重试、窗宽窗位调整都能精确掌握。如果你没有前端人力,iframe 快速上线没问题,但后续做权限集成、单点登录时会后悔。
6.2 帧预取、渐进渲染和窗宽窗位预设
阅片手感的临界点在于连续翻页不卡顿。对应策略是三层:第一层是序列元数据里预读 frame count,翻到某一帧前就并发拉它前后各 20 帧;第二层是渐进渲染,先 JPEG 缩略图铺底、再后台替换原始帧,用户以为加载完了,实际细节还在补;第三层是窗宽窗位预设,CT 脑、肺、骨各存一套预设,切换时用 GPU 加速的 LUT 实现,不要重新拉帧——重新拉帧是最伤性能的误操作。
6.3 验证:用两段脚本把加载瓶颈定位到链路哪一段
上线前花十分钟做一次链路分段测速。第一段测 API 拉帧原始耗时,第二段测浏览器到 CDN 的网络耗时。用 curl 连续取 30 帧,统计平均和 P95:
for uid in $(seq 1 30); do curl -u demo:demo -w "%{time_total}\n" -o /dev/null \ "http://localhost:8080/dicomweb/studies/$STUDY_UID/series/$SERIES_UID/instances/$INST_UID/frames/$uid" \ -H "Accept: application/octet-stream" done平均耗时超过 200 毫秒,问题大概率在 API 转码;平均耗时低但浏览器里帧加载慢,问题在网络并发或前端渲染。我自己在这上面栽过一次:实测 API 本地 30 毫秒,线上却一帧一卡,后来发现是 Chrome 对同一 HTTPS 域名的 6 连接限制,开 HTTP/2 后症状消失。养成习惯:每次改完部署先跑一遍这个脚本,再顺手看一眼整帧加载瀑布图,别让用户替你发现性能问题。希望帮到你。
本文还有配套的精品资源,点击获取